diff --git a/docs/api/saved-objects/find.asciidoc b/docs/api/saved-objects/find.asciidoc index c43b58d3aa98..f04aeb842062 100644 --- a/docs/api/saved-objects/find.asciidoc +++ b/docs/api/saved-objects/find.asciidoc @@ -53,9 +53,14 @@ experimental[] Retrieve a paginated set of {kib} saved objects by various condit (Optional, object) Filters to objects that have a relationship with the type and ID combination. `filter`:: - (Optional, string) The filter is a KQL string with the caveat that if you filter with an attribute from your type saved object. - It should look like that savedObjectType.attributes.title: "myTitle". However, If you used a direct attribute of a saved object like `updatedAt`, - you will have to define your filter like that savedObjectType.updatedAt > 2018-12-22. + (Optional, string) The filter is a KQL string with the caveat that if you filter with an attribute from your saved object type, + it should look like that: `savedObjectType.attributes.title: "myTitle"`. However, If you use a root attribute of a saved + object such as `updated_at`, you will have to define your filter like that: `savedObjectType.updated_at > 2018-12-22`. + +`aggs`:: + (Optional, string) **experimental** An aggregation structure, serialized as a string. The field format is similar to `filter`, meaning + that to use a saved object type attribute in the aggregation, the `savedObjectType.attributes.title`: "myTitle"` format + must be used. For root fields, the syntax is `savedObjectType.rootField` NOTE: As objects change in {kib}, the results on each page of the response also change. Use the find API for traditional paginated results, but avoid using it to export large amounts of data. diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsclient.find.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsclient.find.md index ddd8b207e3d7..fc9652b96450 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsclient.find.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsclient.find.md @@ -9,5 +9,5 @@ Search for objects Signature: ```typescript -find: (options: SavedObjectsFindOptions) => Promise>; +find: (options: SavedObjectsFindOptions) => Promise>; ``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsclient.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsclient.md index 6e53b169b8be..1ec756f8d743 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsclient.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsclient.md @@ -24,7 +24,7 @@ The constructor for this class is marked as internal. Third-party code should no | [bulkGet](./kibana-plugin-core-public.savedobjectsclient.bulkget.md) | | (objects?: Array<{
id: string;
type: string;
}>) => Promise<SavedObjectsBatchResponse<unknown>> | Returns an array of objects by id | | [create](./kibana-plugin-core-public.savedobjectsclient.create.md) | | <T = unknown>(type: string, attributes: T, options?: SavedObjectsCreateOptions) => Promise<SimpleSavedObject<T>> | Persists an object | | [delete](./kibana-plugin-core-public.savedobjectsclient.delete.md) | | (type: string, id: string, options?: SavedObjectsDeleteOptions | undefined) => ReturnType<SavedObjectsApi['delete']> | Deletes an object | -| [find](./kibana-plugin-core-public.savedobjectsclient.find.md) | | <T = unknown>(options: SavedObjectsFindOptions) => Promise<SavedObjectsFindResponsePublic<T>> | Search for objects | +| [find](./kibana-plugin-core-public.savedobjectsclient.find.md) | | <T = unknown, A = unknown>(options: SavedObjectsFindOptions) => Promise<SavedObjectsFindResponsePublic<T, unknown>> | Search for objects | | [get](./kibana-plugin-core-public.savedobjectsclient.get.md) | | <T = unknown>(type: string, id: string) => Promise<SimpleSavedObject<T>> | Fetches a single object | ## Methods diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindresponsepublic.aggregations.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindresponsepublic.aggregations.md new file mode 100644 index 000000000000..14401b02f25c --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindresponsepublic.aggregations.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsFindResponsePublic](./kibana-plugin-core-public.savedobjectsfindresponsepublic.md) > [aggregations](./kibana-plugin-core-public.savedobjectsfindresponsepublic.aggregations.md) + +## SavedObjectsFindResponsePublic.aggregations property + +Signature: + +```typescript +aggregations?: A; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindresponsepublic.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindresponsepublic.md index 7d7587804126..6f2276194f05 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindresponsepublic.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindresponsepublic.md @@ -11,13 +11,14 @@ Return type of the Saved Objects `find()` method. Signature: ```typescript -export interface SavedObjectsFindResponsePublic extends SavedObjectsBatchResponse +export interface SavedObjectsFindResponsePublic extends SavedObjectsBatchResponse ``` ## Properties | Property | Type | Description | | --- | --- | --- | +| [aggregations](./kibana-plugin-core-public.savedobjectsfindresponsepublic.aggregations.md) | A | | | [page](./kibana-plugin-core-public.savedobjectsfindresponsepublic.page.md) | number | | | [perPage](./kibana-plugin-core-public.savedobjectsfindresponsepublic.perpage.md) | number | | | [total](./kibana-plugin-core-public.savedobjectsfindresponsepublic.total.md) | number | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.find.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.find.md index 9a4c3df5d2d9..56d76125108d 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.find.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.find.md @@ -9,7 +9,7 @@ Find all SavedObjects matching the search query Signature: ```typescript -find(options: SavedObjectsFindOptions): Promise>; +find(options: SavedObjectsFindOptions): Promise>; ``` ## Parameters @@ -20,5 +20,5 @@ find(options: SavedObjectsFindOptions): PromiseReturns: -`Promise>` +`Promise>` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresponse.aggregations.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresponse.aggregations.md new file mode 100644 index 000000000000..17a899f4c828 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresponse.aggregations.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsFindResponse](./kibana-plugin-core-server.savedobjectsfindresponse.md) > [aggregations](./kibana-plugin-core-server.savedobjectsfindresponse.aggregations.md) + +## SavedObjectsFindResponse.aggregations property + +Signature: + +```typescript +aggregations?: A; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresponse.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresponse.md index fd56e8ce40e2..8176baf44acb 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresponse.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresponse.md @@ -11,13 +11,14 @@ Return type of the Saved Objects `find()` method. Signature: ```typescript -export interface SavedObjectsFindResponse +export interface SavedObjectsFindResponse ``` ## Properties | Property | Type | Description | | --- | --- | --- | +| [aggregations](./kibana-plugin-core-server.savedobjectsfindresponse.aggregations.md) | A | | | [page](./kibana-plugin-core-server.savedobjectsfindresponse.page.md) | number | | | [per\_page](./kibana-plugin-core-server.savedobjectsfindresponse.per_page.md) | number | | | [pit\_id](./kibana-plugin-core-server.savedobjectsfindresponse.pit_id.md) | string | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.find.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.find.md index d3e93e7af2aa..5c823b756791 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,7 +7,7 @@ Signature: ```typescript -find(options: SavedObjectsFindOptions): Promise>; +find(options: SavedObjectsFindOptions): Promise>; ``` ## Parameters @@ -18,7 +18,7 @@ find(options: SavedObjectsFindOptions): PromiseReturns: -`Promise>` +`Promise>` {promise} - { saved\_objects: \[{ id, type, version, attributes }\], total, per\_page, page } 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 index 40e865cb02ce..23cbebf22aa2 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsutils.createemptyfindresponse.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsutils.createemptyfindresponse.md @@ -9,5 +9,5 @@ Creates an empty response for a find operation. This is only intended to be used Signature: ```typescript -static createEmptyFindResponse: ({ page, perPage, }: SavedObjectsFindOptions) => SavedObjectsFindResponse; +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 8c787364c4cb..0148621e757b 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsutils.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsutils.md @@ -15,7 +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. | +| [createEmptyFindResponse](./kibana-plugin-core-server.savedobjectsutils.createemptyfindresponse.md) | static | <T, A>({ page, perPage, }: SavedObjectsFindOptions) => SavedObjectsFindResponse<T, A> | 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 8c1753c2caba..18133ebec335 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -1224,7 +1224,7 @@ export class SavedObjectsClient { // Warning: (ae-forgotten-export) The symbol "SavedObjectsClientContract" needs to be exported by the entry point index.d.ts delete: (type: string, id: string, options?: SavedObjectsDeleteOptions | undefined) => ReturnType; // Warning: (ae-forgotten-export) The symbol "SavedObjectsFindOptions" needs to be exported by the entry point index.d.ts - find: (options: SavedObjectsFindOptions_2) => Promise>; + find: (options: SavedObjectsFindOptions_2) => Promise>; get: (type: string, id: string) => Promise>; update(type: string, id: string, attributes: T, { version, migrationVersion, references }?: SavedObjectsUpdateOptions): Promise>; } @@ -1244,6 +1244,8 @@ export interface SavedObjectsCreateOptions { // @public (undocumented) export interface SavedObjectsFindOptions { + // @alpha + aggs?: Record; defaultSearchOperator?: 'AND' | 'OR'; fields?: string[]; // Warning: (ae-forgotten-export) The symbol "KueryNode" needs to be exported by the entry point index.d.ts @@ -1284,7 +1286,9 @@ export interface SavedObjectsFindOptionsReference { } // @public -export interface SavedObjectsFindResponsePublic extends SavedObjectsBatchResponse { +export interface SavedObjectsFindResponsePublic extends SavedObjectsBatchResponse { + // (undocumented) + aggregations?: A; // (undocumented) page: number; // (undocumented) diff --git a/src/core/public/saved_objects/saved_objects_client.ts b/src/core/public/saved_objects/saved_objects_client.ts index 44466025de7e..782ffa689704 100644 --- a/src/core/public/saved_objects/saved_objects_client.ts +++ b/src/core/public/saved_objects/saved_objects_client.ts @@ -103,7 +103,9 @@ export interface SavedObjectsDeleteOptions { * * @public */ -export interface SavedObjectsFindResponsePublic extends SavedObjectsBatchResponse { +export interface SavedObjectsFindResponsePublic + extends SavedObjectsBatchResponse { + aggregations?: A; total: number; perPage: number; page: number; @@ -310,7 +312,7 @@ export class SavedObjectsClient { * @property {object} [options.hasReference] - { type, id } * @returns A find result with objects matching the specified search. */ - public find = ( + public find = ( options: SavedObjectsFindOptions ): Promise> => { const path = this.getPath(['_find']); @@ -326,6 +328,7 @@ export class SavedObjectsClient { sortField: 'sort_field', type: 'type', filter: 'filter', + aggs: 'aggs', namespaces: 'namespaces', preference: 'preference', }; @@ -342,6 +345,12 @@ export class SavedObjectsClient { query.has_reference = JSON.stringify(query.has_reference); } + // `aggs` is a structured object. we need to stringify it before sending it, as `fetch` + // is not doing it implicitly. + if (query.aggs) { + query.aggs = JSON.stringify(query.aggs); + } + const request: ReturnType = this.savedObjectsFetch(path, { method: 'GET', query, @@ -349,6 +358,7 @@ export class SavedObjectsClient { return request.then((resp) => { return renameKeys( { + aggregations: 'aggregations', saved_objects: 'savedObjects', total: 'total', per_page: 'perPage', diff --git a/src/core/server/saved_objects/routes/find.ts b/src/core/server/saved_objects/routes/find.ts index 6ba23747cf37..d21039db30e5 100644 --- a/src/core/server/saved_objects/routes/find.ts +++ b/src/core/server/saved_objects/routes/find.ts @@ -44,6 +44,7 @@ export const registerFindRoute = (router: IRouter, { coreUsageData }: RouteDepen has_reference_operator: searchOperatorSchema, fields: schema.maybe(schema.oneOf([schema.string(), schema.arrayOf(schema.string())])), filter: schema.maybe(schema.string()), + aggs: schema.maybe(schema.string()), namespaces: schema.maybe( schema.oneOf([schema.string(), schema.arrayOf(schema.string())]) ), @@ -59,6 +60,20 @@ export const registerFindRoute = (router: IRouter, { coreUsageData }: RouteDepen const usageStatsClient = coreUsageData.getClient(); usageStatsClient.incrementSavedObjectsFind({ request: req }).catch(() => {}); + // manually validation to avoid using JSON.parse twice + let aggs; + if (query.aggs) { + try { + aggs = JSON.parse(query.aggs); + } catch (e) { + return res.badRequest({ + body: { + message: 'invalid aggs value', + }, + }); + } + } + const result = await context.core.savedObjects.client.find({ perPage: query.per_page, page: query.page, @@ -72,6 +87,7 @@ export const registerFindRoute = (router: IRouter, { coreUsageData }: RouteDepen hasReferenceOperator: query.has_reference_operator, fields: typeof query.fields === 'string' ? [query.fields] : query.fields, filter: query.filter, + aggs, namespaces, }); diff --git a/src/core/server/saved_objects/service/lib/aggregations/aggs_types/bucket_aggs.ts b/src/core/server/saved_objects/service/lib/aggregations/aggs_types/bucket_aggs.ts new file mode 100644 index 000000000000..1508cab69a04 --- /dev/null +++ b/src/core/server/saved_objects/service/lib/aggregations/aggs_types/bucket_aggs.ts @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { schema as s, ObjectType } from '@kbn/config-schema'; + +/** + * Schemas for the Bucket aggregations. + * + * Currently supported: + * - filter + * - histogram + * - terms + * + * Not implemented: + * - adjacency_matrix + * - auto_date_histogram + * - children + * - composite + * - date_histogram + * - date_range + * - diversified_sampler + * - filters + * - geo_distance + * - geohash_grid + * - geotile_grid + * - global + * - ip_range + * - missing + * - multi_terms + * - nested + * - parent + * - range + * - rare_terms + * - reverse_nested + * - sampler + * - significant_terms + * - significant_text + * - variable_width_histogram + */ +export const bucketAggsSchemas: Record = { + filter: s.object({ + term: s.recordOf(s.string(), s.oneOf([s.string(), s.boolean(), s.number()])), + }), + histogram: s.object({ + field: s.maybe(s.string()), + interval: s.maybe(s.number()), + min_doc_count: s.maybe(s.number()), + extended_bounds: s.maybe( + s.object({ + min: s.number(), + max: s.number(), + }) + ), + hard_bounds: s.maybe( + s.object({ + min: s.number(), + max: s.number(), + }) + ), + missing: s.maybe(s.number()), + keyed: s.maybe(s.boolean()), + order: s.maybe( + s.object({ + _count: s.string(), + _key: s.string(), + }) + ), + }), + terms: s.object({ + field: s.maybe(s.string()), + collect_mode: s.maybe(s.string()), + exclude: s.maybe(s.oneOf([s.string(), s.arrayOf(s.string())])), + include: s.maybe(s.oneOf([s.string(), s.arrayOf(s.string())])), + execution_hint: s.maybe(s.string()), + missing: s.maybe(s.number()), + min_doc_count: s.maybe(s.number()), + size: s.maybe(s.number()), + show_term_doc_count_error: s.maybe(s.boolean()), + order: s.maybe(s.oneOf([s.literal('asc'), s.literal('desc')])), + }), +}; diff --git a/src/core/server/saved_objects/service/lib/aggregations/aggs_types/index.ts b/src/core/server/saved_objects/service/lib/aggregations/aggs_types/index.ts new file mode 100644 index 000000000000..7967fad0185f --- /dev/null +++ b/src/core/server/saved_objects/service/lib/aggregations/aggs_types/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { bucketAggsSchemas } from './bucket_aggs'; +import { metricsAggsSchemas } from './metrics_aggs'; + +export const aggregationSchemas = { + ...metricsAggsSchemas, + ...bucketAggsSchemas, +}; diff --git a/src/core/server/saved_objects/service/lib/aggregations/aggs_types/metrics_aggs.ts b/src/core/server/saved_objects/service/lib/aggregations/aggs_types/metrics_aggs.ts new file mode 100644 index 000000000000..c05ae67cd216 --- /dev/null +++ b/src/core/server/saved_objects/service/lib/aggregations/aggs_types/metrics_aggs.ts @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { schema as s, ObjectType } from '@kbn/config-schema'; + +/** + * Schemas for the metrics Aggregations + * + * Currently supported: + * - avg + * - cardinality + * - min + * - max + * - sum + * - top_hits + * - weighted_avg + * + * Not implemented: + * - boxplot + * - extended_stats + * - geo_bounds + * - geo_centroid + * - geo_line + * - matrix_stats + * - median_absolute_deviation + * - percentile_ranks + * - percentiles + * - rate + * - scripted_metric + * - stats + * - string_stats + * - t_test + * - value_count + */ +export const metricsAggsSchemas: Record = { + avg: s.object({ + field: s.maybe(s.string()), + missing: s.maybe(s.oneOf([s.string(), s.number(), s.boolean()])), + }), + cardinality: s.object({ + field: s.maybe(s.string()), + precision_threshold: s.maybe(s.number()), + rehash: s.maybe(s.boolean()), + missing: s.maybe(s.oneOf([s.string(), s.number(), s.boolean()])), + }), + min: s.object({ + field: s.maybe(s.string()), + missing: s.maybe(s.oneOf([s.string(), s.number(), s.boolean()])), + format: s.maybe(s.string()), + }), + max: s.object({ + field: s.maybe(s.string()), + missing: s.maybe(s.oneOf([s.string(), s.number(), s.boolean()])), + format: s.maybe(s.string()), + }), + sum: s.object({ + field: s.maybe(s.string()), + missing: s.maybe(s.oneOf([s.string(), s.number(), s.boolean()])), + }), + top_hits: s.object({ + explain: s.maybe(s.boolean()), + docvalue_fields: s.maybe(s.oneOf([s.string(), s.arrayOf(s.string())])), + stored_fields: s.maybe(s.oneOf([s.string(), s.arrayOf(s.string())])), + from: s.maybe(s.number()), + size: s.maybe(s.number()), + sort: s.maybe(s.oneOf([s.literal('asc'), s.literal('desc')])), + seq_no_primary_term: s.maybe(s.boolean()), + version: s.maybe(s.boolean()), + track_scores: s.maybe(s.boolean()), + highlight: s.maybe(s.any()), + _source: s.maybe(s.oneOf([s.boolean(), s.string(), s.arrayOf(s.string())])), + }), + weighted_avg: s.object({ + format: s.maybe(s.string()), + value_type: s.maybe(s.string()), + value: s.maybe( + s.object({ + field: s.maybe(s.string()), + missing: s.maybe(s.number()), + }) + ), + weight: s.maybe( + s.object({ + field: s.maybe(s.string()), + missing: s.maybe(s.number()), + }) + ), + }), +}; diff --git a/src/core/server/saved_objects/service/lib/aggregations/index.ts b/src/core/server/saved_objects/service/lib/aggregations/index.ts new file mode 100644 index 000000000000..f71d3e8daea9 --- /dev/null +++ b/src/core/server/saved_objects/service/lib/aggregations/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { validateAndConvertAggregations } from './validation'; diff --git a/src/core/server/saved_objects/service/lib/aggregations/validation.test.ts b/src/core/server/saved_objects/service/lib/aggregations/validation.test.ts new file mode 100644 index 000000000000..8a7c1c3719eb --- /dev/null +++ b/src/core/server/saved_objects/service/lib/aggregations/validation.test.ts @@ -0,0 +1,431 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { estypes } from '@elastic/elasticsearch'; +import { validateAndConvertAggregations } from './validation'; + +type AggsMap = Record; + +const mockMappings = { + properties: { + updated_at: { + type: 'date', + }, + foo: { + properties: { + title: { + type: 'text', + }, + description: { + type: 'text', + }, + bytes: { + type: 'number', + }, + }, + }, + bean: { + properties: { + canned: { + fields: { + text: { + type: 'text', + }, + }, + type: 'keyword', + }, + }, + }, + alert: { + properties: { + actions: { + type: 'nested', + properties: { + group: { + type: 'keyword', + }, + actionRef: { + type: 'keyword', + }, + actionTypeId: { + type: 'keyword', + }, + params: { + enabled: false, + type: 'object', + }, + }, + }, + params: { + type: 'flattened', + }, + }, + }, + }, +}; + +describe('validateAndConvertAggregations', () => { + it('validates a simple aggregations', () => { + expect( + validateAndConvertAggregations( + ['foo'], + { aggName: { max: { field: 'foo.attributes.bytes' } } }, + mockMappings + ) + ).toEqual({ + aggName: { + max: { + field: 'foo.bytes', + }, + }, + }); + }); + + it('validates a nested field in simple aggregations', () => { + expect( + validateAndConvertAggregations( + ['alert'], + { aggName: { cardinality: { field: 'alert.attributes.actions.group' } } }, + mockMappings + ) + ).toEqual({ + aggName: { + cardinality: { + field: 'alert.actions.group', + }, + }, + }); + }); + + it('validates a nested aggregations', () => { + expect( + validateAndConvertAggregations( + ['alert'], + { + aggName: { + cardinality: { + field: 'alert.attributes.actions.group', + }, + aggs: { + aggName: { + max: { field: 'alert.attributes.actions.group' }, + }, + }, + }, + }, + mockMappings + ) + ).toEqual({ + aggName: { + cardinality: { + field: 'alert.actions.group', + }, + aggs: { + aggName: { + max: { + field: 'alert.actions.group', + }, + }, + }, + }, + }); + }); + + it('validates a deeply nested aggregations', () => { + expect( + validateAndConvertAggregations( + ['alert'], + { + first: { + cardinality: { + field: 'alert.attributes.actions.group', + }, + aggs: { + second: { + max: { field: 'alert.attributes.actions.group' }, + aggs: { + third: { + min: { + field: 'alert.attributes.actions.actionTypeId', + }, + }, + }, + }, + }, + }, + }, + mockMappings + ) + ).toEqual({ + first: { + cardinality: { + field: 'alert.actions.group', + }, + aggs: { + second: { + max: { field: 'alert.actions.group' }, + aggs: { + third: { + min: { + field: 'alert.actions.actionTypeId', + }, + }, + }, + }, + }, + }, + }); + }); + + it('rewrites type attributes when valid', () => { + const aggregations: AggsMap = { + average: { + avg: { + field: 'alert.attributes.actions.group', + missing: 10, + }, + }, + }; + expect(validateAndConvertAggregations(['alert'], aggregations, mockMappings)).toEqual({ + average: { + avg: { + field: 'alert.actions.group', + missing: 10, + }, + }, + }); + }); + + it('rewrites root attributes when valid', () => { + const aggregations: AggsMap = { + average: { + avg: { + field: 'alert.updated_at', + missing: 10, + }, + }, + }; + expect(validateAndConvertAggregations(['alert'], aggregations, mockMappings)).toEqual({ + average: { + avg: { + field: 'updated_at', + missing: 10, + }, + }, + }); + }); + + it('throws an error when the `field` name is not using attributes path', () => { + const aggregations: AggsMap = { + average: { + avg: { + field: 'alert.actions.group', + missing: 10, + }, + }, + }; + expect(() => + validateAndConvertAggregations(['alert'], aggregations, mockMappings) + ).toThrowErrorMatchingInlineSnapshot( + `"[average.avg.field] Invalid attribute path: alert.actions.group"` + ); + }); + + it('throws an error when the `field` name is referencing an invalid field', () => { + const aggregations: AggsMap = { + average: { + avg: { + field: 'alert.attributes.actions.non_existing', + missing: 10, + }, + }, + }; + expect(() => + validateAndConvertAggregations(['alert'], aggregations, mockMappings) + ).toThrowErrorMatchingInlineSnapshot( + `"[average.avg.field] Invalid attribute path: alert.attributes.actions.non_existing"` + ); + }); + + it('throws an error when the attribute path is referencing an invalid root field', () => { + const aggregations: AggsMap = { + average: { + avg: { + field: 'alert.bad_root', + missing: 10, + }, + }, + }; + expect(() => + validateAndConvertAggregations(['alert'], aggregations, mockMappings) + ).toThrowErrorMatchingInlineSnapshot( + `"[average.avg.field] Invalid attribute path: alert.bad_root"` + ); + }); + + it('rewrites the `field` name even when nested', () => { + const aggregations: AggsMap = { + average: { + weighted_avg: { + value: { + field: 'alert.attributes.actions.group', + missing: 10, + }, + weight: { + field: 'alert.attributes.actions.actionRef', + }, + }, + }, + }; + expect(validateAndConvertAggregations(['alert'], aggregations, mockMappings)).toEqual({ + average: { + weighted_avg: { + value: { + field: 'alert.actions.group', + missing: 10, + }, + weight: { + field: 'alert.actions.actionRef', + }, + }, + }, + }); + }); + + it('rewrites the entries of a filter term record', () => { + const aggregations: AggsMap = { + myFilter: { + filter: { + term: { + 'foo.attributes.description': 'hello', + 'foo.attributes.bytes': 10, + }, + }, + }, + }; + expect(validateAndConvertAggregations(['foo'], aggregations, mockMappings)).toEqual({ + myFilter: { + filter: { + term: { 'foo.description': 'hello', 'foo.bytes': 10 }, + }, + }, + }); + }); + + it('throws an error when referencing non-allowed types', () => { + const aggregations: AggsMap = { + myFilter: { + max: { + field: 'foo.attributes.bytes', + }, + }, + }; + + expect(() => { + validateAndConvertAggregations(['alert'], aggregations, mockMappings); + }).toThrowErrorMatchingInlineSnapshot( + `"[myFilter.max.field] Invalid attribute path: foo.attributes.bytes"` + ); + }); + + it('throws an error when an attributes is not respecting its schema definition', () => { + const aggregations: AggsMap = { + someAgg: { + terms: { + missing: 'expecting a number', + }, + }, + }; + + expect(() => + validateAndConvertAggregations(['alert'], aggregations, mockMappings) + ).toThrowErrorMatchingInlineSnapshot( + `"[someAgg.terms.missing]: expected value of type [number] but got [string]"` + ); + }); + + it('throws an error when trying to validate an unknown aggregation type', () => { + const aggregations: AggsMap = { + someAgg: { + auto_date_histogram: { + field: 'foo.attributes.bytes', + }, + }, + }; + + expect(() => { + validateAndConvertAggregations(['foo'], aggregations, mockMappings); + }).toThrowErrorMatchingInlineSnapshot( + `"[someAgg.auto_date_histogram] auto_date_histogram aggregation is not valid (or not registered yet)"` + ); + }); + + it('throws an error when a child aggregation is unknown', () => { + const aggregations: AggsMap = { + someAgg: { + max: { + field: 'foo.attributes.bytes', + }, + aggs: { + unknownAgg: { + cumulative_cardinality: { + format: 'format', + }, + }, + }, + }, + }; + + expect(() => { + validateAndConvertAggregations(['foo'], aggregations, mockMappings); + }).toThrowErrorMatchingInlineSnapshot( + `"[someAgg.aggs.unknownAgg.cumulative_cardinality] cumulative_cardinality aggregation is not valid (or not registered yet)"` + ); + }); + + it('throws an error when using a script attribute', () => { + const aggregations: AggsMap = { + someAgg: { + max: { + field: 'foo.attributes.bytes', + script: 'This is a bad script', + }, + }, + }; + + expect(() => { + validateAndConvertAggregations(['foo'], aggregations, mockMappings); + }).toThrowErrorMatchingInlineSnapshot( + `"[someAgg.max.script]: definition for this key is missing"` + ); + }); + + it('throws an error when using a script attribute in a nested aggregation', () => { + const aggregations: AggsMap = { + someAgg: { + min: { + field: 'foo.attributes.bytes', + }, + aggs: { + nested: { + max: { + field: 'foo.attributes.bytes', + script: 'This is a bad script', + }, + }, + }, + }, + }; + + expect(() => { + validateAndConvertAggregations(['foo'], aggregations, mockMappings); + }).toThrowErrorMatchingInlineSnapshot( + `"[someAgg.aggs.nested.max.script]: definition for this key is missing"` + ); + }); +}); diff --git a/src/core/server/saved_objects/service/lib/aggregations/validation.ts b/src/core/server/saved_objects/service/lib/aggregations/validation.ts new file mode 100644 index 000000000000..a2fd39218313 --- /dev/null +++ b/src/core/server/saved_objects/service/lib/aggregations/validation.ts @@ -0,0 +1,229 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { estypes } from '@elastic/elasticsearch'; +import { ObjectType } from '@kbn/config-schema'; +import { isPlainObject } from 'lodash'; + +import { IndexMapping } from '../../../mappings'; +import { + isObjectTypeAttribute, + rewriteObjectTypeAttribute, + isRootLevelAttribute, + rewriteRootLevelAttribute, +} from './validation_utils'; +import { aggregationSchemas } from './aggs_types'; + +const aggregationKeys = ['aggs', 'aggregations']; + +interface ValidationContext { + allowedTypes: string[]; + indexMapping: IndexMapping; + currentPath: string[]; +} + +/** + * Validate an aggregation structure against the declared mappings and + * aggregation schemas, and rewrite the attribute fields using the KQL-like syntax + * - `{type}.attributes.{attribute}` to `{type}.{attribute}` + * - `{type}.{rootField}` to `{rootField}` + * + * throws on the first validation error if any is encountered. + */ +export const validateAndConvertAggregations = ( + allowedTypes: string[], + aggs: Record, + indexMapping: IndexMapping +): Record => { + return validateAggregations(aggs, { + allowedTypes, + indexMapping, + currentPath: [], + }); +}; + +/** + * Validate a record of aggregation containers, + * Which can either be the root level aggregations (`SearchRequest.body.aggs`) + * Or a nested record of aggregation (`SearchRequest.body.aggs.myAggregation.aggs`) + */ +const validateAggregations = ( + aggregations: Record, + context: ValidationContext +) => { + return Object.entries(aggregations).reduce((memo, [aggrName, aggrContainer]) => { + memo[aggrName] = validateAggregation(aggrContainer, childContext(context, aggrName)); + return memo; + }, {} as Record); +}; + +/** + * Validate an aggregation container, e.g an entry of `SearchRequest.body.aggs`, or + * from a nested aggregation record, including its potential nested aggregations. + */ +const validateAggregation = ( + aggregation: estypes.AggregationContainer, + context: ValidationContext +) => { + const container = validateAggregationContainer(aggregation, context); + + if (aggregation.aggregations) { + container.aggregations = validateAggregations( + aggregation.aggregations, + childContext(context, 'aggregations') + ); + } + if (aggregation.aggs) { + container.aggs = validateAggregations(aggregation.aggs, childContext(context, 'aggs')); + } + + return container; +}; + +/** + * Validates root-level aggregation of given aggregation container + * (ignoring its nested aggregations) + */ +const validateAggregationContainer = ( + container: estypes.AggregationContainer, + context: ValidationContext +) => { + return Object.entries(container).reduce((memo, [aggName, aggregation]) => { + if (aggregationKeys.includes(aggName)) { + return memo; + } + return { + ...memo, + [aggName]: validateAggregationType(aggName, aggregation, childContext(context, aggName)), + }; + }, {} as estypes.AggregationContainer); +}; + +const validateAggregationType = ( + aggregationType: string, + aggregation: Record, + context: ValidationContext +) => { + const aggregationSchema = aggregationSchemas[aggregationType]; + if (!aggregationSchema) { + throw new Error( + `[${context.currentPath.join( + '.' + )}] ${aggregationType} aggregation is not valid (or not registered yet)` + ); + } + + validateAggregationStructure(aggregationSchema, aggregation, context); + return validateAndRewriteFieldAttributes(aggregation, context); +}; + +/** + * Validate an aggregation structure against its declared schema. + */ +const validateAggregationStructure = ( + schema: ObjectType, + aggObject: unknown, + context: ValidationContext +) => { + return schema.validate(aggObject, {}, context.currentPath.join('.')); +}; + +/** + * List of fields that have an attribute path as value + * + * @example + * ```ts + * avg: { + * field: 'alert.attributes.actions.group', + * }, + * ``` + */ +const attributeFields = ['field']; +/** + * List of fields that have a Record as value + * + * @example + * ```ts + * filter: { + * term: { + * 'alert.attributes.actions.group': 'value' + * }, + * }, + * ``` + */ +const attributeMaps = ['term']; + +const validateAndRewriteFieldAttributes = ( + aggregation: Record, + context: ValidationContext +) => { + return recursiveRewrite(aggregation, context, []); +}; + +const recursiveRewrite = ( + currentLevel: Record, + context: ValidationContext, + parents: string[] +): Record => { + return Object.entries(currentLevel).reduce((memo, [key, value]) => { + const rewriteKey = isAttributeKey(parents); + const rewriteValue = isAttributeValue(key, value); + + const nestedContext = childContext(context, key); + const newKey = rewriteKey ? validateAndRewriteAttributePath(key, nestedContext) : key; + const newValue = rewriteValue + ? validateAndRewriteAttributePath(value, nestedContext) + : isPlainObject(value) + ? recursiveRewrite(value, nestedContext, [...parents, key]) + : value; + + return { + ...memo, + [newKey]: newValue, + }; + }, {}); +}; + +const childContext = (context: ValidationContext, path: string): ValidationContext => { + return { + ...context, + currentPath: [...context.currentPath, path], + }; +}; + +const lastParent = (parents: string[]) => { + if (parents.length) { + return parents[parents.length - 1]; + } + return undefined; +}; + +const isAttributeKey = (parents: string[]) => { + const last = lastParent(parents); + if (last) { + return attributeMaps.includes(last); + } + return false; +}; + +const isAttributeValue = (fieldName: string, fieldValue: unknown): boolean => { + return attributeFields.includes(fieldName) && typeof fieldValue === 'string'; +}; + +const validateAndRewriteAttributePath = ( + attributePath: string, + { allowedTypes, indexMapping, currentPath }: ValidationContext +) => { + if (isRootLevelAttribute(attributePath, indexMapping, allowedTypes)) { + return rewriteRootLevelAttribute(attributePath); + } + if (isObjectTypeAttribute(attributePath, indexMapping, allowedTypes)) { + return rewriteObjectTypeAttribute(attributePath); + } + throw new Error(`[${currentPath.join('.')}] Invalid attribute path: ${attributePath}`); +}; diff --git a/src/core/server/saved_objects/service/lib/aggregations/validation_utils.test.ts b/src/core/server/saved_objects/service/lib/aggregations/validation_utils.test.ts new file mode 100644 index 000000000000..25c3aea474ec --- /dev/null +++ b/src/core/server/saved_objects/service/lib/aggregations/validation_utils.test.ts @@ -0,0 +1,148 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { IndexMapping } from '../../../mappings'; +import { + isRootLevelAttribute, + rewriteRootLevelAttribute, + isObjectTypeAttribute, + rewriteObjectTypeAttribute, +} from './validation_utils'; + +const mockMappings: IndexMapping = { + properties: { + updated_at: { + type: 'date', + }, + foo: { + properties: { + title: { + type: 'text', + }, + description: { + type: 'text', + }, + bytes: { + type: 'number', + }, + }, + }, + bean: { + properties: { + canned: { + fields: { + text: { + type: 'text', + }, + }, + type: 'keyword', + }, + }, + }, + alert: { + properties: { + actions: { + type: 'nested', + properties: { + group: { + type: 'keyword', + }, + actionRef: { + type: 'keyword', + }, + actionTypeId: { + type: 'keyword', + }, + params: { + enabled: false, + type: 'object', + }, + }, + }, + params: { + type: 'flattened', + }, + }, + }, + }, +}; + +describe('isRootLevelAttribute', () => { + it('returns true when referring to a path to a valid root level field', () => { + expect(isRootLevelAttribute('foo.updated_at', mockMappings, ['foo'])).toBe(true); + }); + it('returns false when referring to a direct path to a valid root level field', () => { + expect(isRootLevelAttribute('updated_at', mockMappings, ['foo'])).toBe(false); + }); + it('returns false when referring to a path to a unknown root level field', () => { + expect(isRootLevelAttribute('foo.not_present', mockMappings, ['foo'])).toBe(false); + }); + it('returns false when referring to a path to an existing nested field', () => { + expect(isRootLevelAttribute('foo.properties.title', mockMappings, ['foo'])).toBe(false); + }); + it('returns false when referring to a path to a valid root level field of an unknown type', () => { + expect(isRootLevelAttribute('bar.updated_at', mockMappings, ['foo'])).toBe(false); + }); + it('returns false when referring to a path to a valid root level type field', () => { + expect(isRootLevelAttribute('foo.foo', mockMappings, ['foo'])).toBe(false); + }); +}); + +describe('rewriteRootLevelAttribute', () => { + it('rewrites the attribute path to strip the type', () => { + expect(rewriteRootLevelAttribute('foo.references')).toEqual('references'); + }); + it('does not handle real root level path', () => { + expect(rewriteRootLevelAttribute('references')).not.toEqual('references'); + }); +}); + +describe('isObjectTypeAttribute', () => { + it('return true if attribute path is valid', () => { + expect(isObjectTypeAttribute('foo.attributes.description', mockMappings, ['foo'])).toEqual( + true + ); + }); + + it('return true for nested attributes', () => { + expect(isObjectTypeAttribute('bean.attributes.canned.text', mockMappings, ['bean'])).toEqual( + true + ); + }); + + it('return false if attribute path points to an invalid type', () => { + expect(isObjectTypeAttribute('foo.attributes.description', mockMappings, ['bean'])).toEqual( + false + ); + }); + + it('returns false if attribute path refers to a type', () => { + expect(isObjectTypeAttribute('bean', mockMappings, ['bean'])).toEqual(false); + }); + + it('Return error if key does not match SO attribute structure', () => { + expect(isObjectTypeAttribute('bean.canned.text', mockMappings, ['bean'])).toEqual(false); + }); + + it('Return false if key matches nested type attribute parent', () => { + expect(isObjectTypeAttribute('alert.actions', mockMappings, ['alert'])).toEqual(false); + }); + + it('returns false if path refers to a non-existent attribute', () => { + expect(isObjectTypeAttribute('bean.attributes.red', mockMappings, ['bean'])).toEqual(false); + }); +}); + +describe('rewriteObjectTypeAttribute', () => { + it('rewrites the attribute path to strip the type', () => { + expect(rewriteObjectTypeAttribute('foo.attributes.prop')).toEqual('foo.prop'); + }); + it('returns invalid input unchanged', () => { + expect(rewriteObjectTypeAttribute('foo.references')).toEqual('foo.references'); + }); +}); diff --git a/src/core/server/saved_objects/service/lib/aggregations/validation_utils.ts b/src/core/server/saved_objects/service/lib/aggregations/validation_utils.ts new file mode 100644 index 000000000000..f817497e3759 --- /dev/null +++ b/src/core/server/saved_objects/service/lib/aggregations/validation_utils.ts @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { IndexMapping } from '../../../mappings'; +import { fieldDefined, hasFilterKeyError } from '../filter_utils'; + +/** + * Returns true if the given attribute path is a valid root level SO attribute path + * + * @example + * ```ts + * isRootLevelAttribute('myType.updated_at', indexMapping, ['myType']}) + * // => true + * ``` + */ +export const isRootLevelAttribute = ( + attributePath: string, + indexMapping: IndexMapping, + allowedTypes: string[] +): boolean => { + const splits = attributePath.split('.'); + if (splits.length !== 2) { + return false; + } + + const [type, fieldName] = splits; + if (allowedTypes.includes(fieldName)) { + return false; + } + return allowedTypes.includes(type) && fieldDefined(indexMapping, fieldName); +}; + +/** + * Rewrites a root level attribute path to strip the type + * + * @example + * ```ts + * rewriteRootLevelAttribute('myType.updated_at') + * // => 'updated_at' + * ``` + */ +export const rewriteRootLevelAttribute = (attributePath: string) => { + return attributePath.split('.')[1]; +}; + +/** + * Returns true if the given attribute path is a valid object type level SO attribute path + * + * @example + * ```ts + * isObjectTypeAttribute('myType.attributes.someField', indexMapping, ['myType']}) + * // => true + * ``` + */ +export const isObjectTypeAttribute = ( + attributePath: string, + indexMapping: IndexMapping, + allowedTypes: string[] +): boolean => { + const error = hasFilterKeyError(attributePath, allowedTypes, indexMapping); + return error == null; +}; + +/** + * Rewrites a object type attribute path to strip the type + * + * @example + * ```ts + * rewriteObjectTypeAttribute('myType.attributes.foo') + * // => 'myType.foo' + * ``` + */ +export const rewriteObjectTypeAttribute = (attributePath: string) => { + return attributePath.replace('.attributes', ''); +}; diff --git a/src/core/server/saved_objects/service/lib/filter_utils.test.ts b/src/core/server/saved_objects/service/lib/filter_utils.test.ts index b50326627cf0..956a60b23809 100644 --- a/src/core/server/saved_objects/service/lib/filter_utils.test.ts +++ b/src/core/server/saved_objects/service/lib/filter_utils.test.ts @@ -18,7 +18,7 @@ import { const mockMappings = { properties: { - updatedAt: { + updated_at: { type: 'date', }, foo: { @@ -123,12 +123,12 @@ describe('Filter Utils', () => { expect( validateConvertFilterToKueryNode( ['foo'], - 'foo.updatedAt: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.attributes.description :*)', + 'foo.updated_at: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.attributes.description :*)', mockMappings ) ).toEqual( esKuery.fromKueryExpression( - '(type: foo and updatedAt: 5678654567) and foo.bytes > 1000 and foo.bytes < 8000 and foo.title: "best" and (foo.description: t* or foo.description :*)' + '(type: foo and updated_at: 5678654567) and foo.bytes > 1000 and foo.bytes < 8000 and foo.title: "best" and (foo.description: t* or foo.description :*)' ) ); }); @@ -137,12 +137,12 @@ describe('Filter Utils', () => { expect( validateConvertFilterToKueryNode( ['foo', 'bar'], - 'foo.updatedAt: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.attributes.description :*)', + 'foo.updated_at: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.attributes.description :*)', mockMappings ) ).toEqual( esKuery.fromKueryExpression( - '(type: foo and updatedAt: 5678654567) and foo.bytes > 1000 and foo.bytes < 8000 and foo.title: "best" and (foo.description: t* or foo.description :*)' + '(type: foo and updated_at: 5678654567) and foo.bytes > 1000 and foo.bytes < 8000 and foo.title: "best" and (foo.description: t* or foo.description :*)' ) ); }); @@ -151,12 +151,12 @@ describe('Filter Utils', () => { expect( validateConvertFilterToKueryNode( ['foo', 'bar'], - '(bar.updatedAt: 5678654567 OR foo.updatedAt: 5678654567) and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or bar.attributes.description :*)', + '(bar.updated_at: 5678654567 OR foo.updated_at: 5678654567) and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or bar.attributes.description :*)', mockMappings ) ).toEqual( esKuery.fromKueryExpression( - '((type: bar and updatedAt: 5678654567) or (type: foo and updatedAt: 5678654567)) and foo.bytes > 1000 and foo.bytes < 8000 and foo.title: "best" and (foo.description: t* or bar.description :*)' + '((type: bar and updated_at: 5678654567) or (type: foo and updated_at: 5678654567)) and foo.bytes > 1000 and foo.bytes < 8000 and foo.title: "best" and (foo.description: t* or bar.description :*)' ) ); }); @@ -181,11 +181,11 @@ describe('Filter Utils', () => { expect(() => { validateConvertFilterToKueryNode( ['foo', 'bar'], - 'updatedAt: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.attributes.description :*)', + 'updated_at: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.attributes.description :*)', mockMappings ); }).toThrowErrorMatchingInlineSnapshot( - `"This key 'updatedAt' need to be wrapped by a saved object type like foo,bar: Bad Request"` + `"This key 'updated_at' need to be wrapped by a saved object type like foo,bar: Bad Request"` ); }); @@ -200,7 +200,7 @@ describe('Filter Utils', () => { test('Validate filter query through KueryNode - happy path', () => { const validationObject = validateFilterKueryNode({ astFilter: esKuery.fromKueryExpression( - 'foo.updatedAt: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.attributes.description :*)' + 'foo.updated_at: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.attributes.description :*)' ), types: ['foo'], indexMapping: mockMappings, @@ -211,7 +211,7 @@ describe('Filter Utils', () => { astPath: 'arguments.0', error: null, isSavedObjectAttr: true, - key: 'foo.updatedAt', + key: 'foo.updated_at', type: 'foo', }, { @@ -275,7 +275,7 @@ describe('Filter Utils', () => { test('Return Error if key is not wrapper by a saved object type', () => { const validationObject = validateFilterKueryNode({ astFilter: esKuery.fromKueryExpression( - 'updatedAt: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.attributes.description :*)' + 'updated_at: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.attributes.description :*)' ), types: ['foo'], indexMapping: mockMappings, @@ -284,9 +284,9 @@ describe('Filter Utils', () => { expect(validationObject).toEqual([ { astPath: 'arguments.0', - error: "This key 'updatedAt' need to be wrapped by a saved object type like foo", + error: "This key 'updated_at' need to be wrapped by a saved object type like foo", isSavedObjectAttr: true, - key: 'updatedAt', + key: 'updated_at', type: null, }, { @@ -330,7 +330,7 @@ describe('Filter Utils', () => { test('Return Error if key of a saved object type is not wrapped with attributes', () => { const validationObject = validateFilterKueryNode({ astFilter: esKuery.fromKueryExpression( - 'foo.updatedAt: 5678654567 and foo.attributes.bytes > 1000 and foo.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.description :*)' + 'foo.updated_at: 5678654567 and foo.attributes.bytes > 1000 and foo.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.description :*)' ), types: ['foo'], indexMapping: mockMappings, @@ -341,7 +341,7 @@ describe('Filter Utils', () => { astPath: 'arguments.0', error: null, isSavedObjectAttr: true, - key: 'foo.updatedAt', + key: 'foo.updated_at', type: 'foo', }, { @@ -387,7 +387,7 @@ describe('Filter Utils', () => { test('Return Error if filter is not using an allowed type', () => { const validationObject = validateFilterKueryNode({ astFilter: esKuery.fromKueryExpression( - 'bar.updatedAt: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.attributes.description :*)' + 'bar.updated_at: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.attributes.description :*)' ), types: ['foo'], indexMapping: mockMappings, @@ -398,7 +398,7 @@ describe('Filter Utils', () => { astPath: 'arguments.0', error: 'This type bar is not allowed', isSavedObjectAttr: true, - key: 'bar.updatedAt', + key: 'bar.updated_at', type: 'bar', }, { @@ -442,7 +442,7 @@ describe('Filter Utils', () => { test('Return Error if filter is using an non-existing key in the index patterns of the saved object type', () => { const validationObject = validateFilterKueryNode({ astFilter: esKuery.fromKueryExpression( - 'foo.updatedAt33: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.header: "best" and (foo.attributes.description: t* or foo.attributes.description :*)' + 'foo.updated_at33: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.header: "best" and (foo.attributes.description: t* or foo.attributes.description :*)' ), types: ['foo'], indexMapping: mockMappings, @@ -451,9 +451,9 @@ describe('Filter Utils', () => { expect(validationObject).toEqual([ { astPath: 'arguments.0', - error: "This key 'foo.updatedAt33' does NOT exist in foo saved object index patterns", + error: "This key 'foo.updated_at33' does NOT exist in foo saved object index patterns", isSavedObjectAttr: false, - key: 'foo.updatedAt33', + key: 'foo.updated_at33', type: 'foo', }, { @@ -519,6 +519,33 @@ describe('Filter Utils', () => { }, ]); }); + + test('Validate multiple items nested filter query through KueryNode', () => { + const validationObject = validateFilterKueryNode({ + astFilter: esKuery.fromKueryExpression( + 'alert.attributes.actions:{ actionTypeId: ".server-log" AND actionRef: "foo" }' + ), + types: ['alert'], + indexMapping: mockMappings, + }); + + expect(validationObject).toEqual([ + { + astPath: 'arguments.1.arguments.0', + error: null, + isSavedObjectAttr: false, + key: 'alert.attributes.actions.actionTypeId', + type: 'alert', + }, + { + astPath: 'arguments.1.arguments.1', + error: null, + isSavedObjectAttr: false, + key: 'alert.attributes.actions.actionRef', + type: 'alert', + }, + ]); + }); }); describe('#hasFilterKeyError', () => { diff --git a/src/core/server/saved_objects/service/lib/filter_utils.ts b/src/core/server/saved_objects/service/lib/filter_utils.ts index 688b7ad96e8e..b3bcef9a62e1 100644 --- a/src/core/server/saved_objects/service/lib/filter_utils.ts +++ b/src/core/server/saved_objects/service/lib/filter_utils.ts @@ -109,7 +109,15 @@ export const validateFilterKueryNode = ({ return astFilter.arguments.reduce((kueryNode: string[], ast: KueryNode, index: number) => { if (hasNestedKey && ast.type === 'literal' && ast.value != null) { localNestedKeys = ast.value; + } else if (ast.type === 'literal' && ast.value && typeof ast.value === 'string') { + const key = ast.value.replace('.attributes', ''); + const mappingKey = 'properties.' + key.split('.').join('.properties.'); + const field = get(indexMapping, mappingKey); + if (field != null && field.type === 'nested') { + localNestedKeys = ast.value; + } } + if (ast.arguments) { const myPath = `${path}.${index}`; return [ @@ -121,7 +129,7 @@ export const validateFilterKueryNode = ({ storeValue: ast.type === 'function' && astFunctionType.includes(ast.function), path: `${myPath}.arguments`, hasNestedKey: ast.type === 'function' && ast.function === 'nested', - nestedKeys: localNestedKeys, + nestedKeys: localNestedKeys || nestedKeys, }), ]; } @@ -226,7 +234,7 @@ export const fieldDefined = (indexMappings: IndexMapping, key: string): boolean return true; } - // If the path is for a flattned type field, we'll assume the mappings are defined. + // If the path is for a flattened type field, we'll assume the mappings are defined. const keys = key.split('.'); for (let i = 0; i < keys.length; i++) { const path = `properties.${keys.slice(0, i + 1).join('.properties.')}`; diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index 7c719ac56a83..c0e2cdc33336 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -66,6 +66,7 @@ import { import { LegacyUrlAlias, LEGACY_URL_ALIAS_TYPE } from '../../object_types'; import { SavedObjectTypeRegistry } from '../../saved_objects_type_registry'; import { validateConvertFilterToKueryNode } from './filter_utils'; +import { validateAndConvertAggregations } from './aggregations'; import { ALL_NAMESPACES_STRING, FIND_DEFAULT_PAGE, @@ -748,7 +749,9 @@ export class SavedObjectsRepository { * @property {string} [options.preference] * @returns {promise} - { saved_objects: [{ id, type, version, attributes }], total, per_page, page } */ - async find(options: SavedObjectsFindOptions): Promise> { + async find( + options: SavedObjectsFindOptions + ): Promise> { const { search, defaultSearchOperator = 'OR', @@ -768,6 +771,7 @@ export class SavedObjectsRepository { typeToNamespacesMap, filter, preference, + aggs, } = options; if (!type && !typeToNamespacesMap) { @@ -799,7 +803,7 @@ export class SavedObjectsRepository { : Array.from(typeToNamespacesMap!.keys()); const allowedTypes = types.filter((t) => this._allowedTypes.includes(t)); if (allowedTypes.length === 0) { - return SavedObjectsUtils.createEmptyFindResponse(options); + return SavedObjectsUtils.createEmptyFindResponse(options); } if (searchFields && !Array.isArray(searchFields)) { @@ -811,16 +815,24 @@ export class SavedObjectsRepository { } let kueryNode; - - try { - if (filter) { + if (filter) { + try { kueryNode = validateConvertFilterToKueryNode(allowedTypes, filter, this._mappings); + } catch (e) { + if (e.name === 'KQLSyntaxError') { + throw SavedObjectsErrorHelpers.createBadRequestError(`KQLSyntaxError: ${e.message}`); + } else { + throw e; + } } - } catch (e) { - if (e.name === 'KQLSyntaxError') { - throw SavedObjectsErrorHelpers.createBadRequestError('KQLSyntaxError: ' + e.message); - } else { - throw e; + } + + let aggsObject; + if (aggs) { + try { + aggsObject = validateAndConvertAggregations(allowedTypes, aggs, this._mappings); + } catch (e) { + throw SavedObjectsErrorHelpers.createBadRequestError(`Invalid aggregation: ${e.message}`); } } @@ -838,6 +850,7 @@ export class SavedObjectsRepository { seq_no_primary_term: true, from: perPage * (page - 1), _source: includedFields(type, fields), + ...(aggsObject ? { aggs: aggsObject } : {}), ...getSearchDsl(this._mappings, this._registry, { search, defaultSearchOperator, @@ -872,6 +885,7 @@ export class SavedObjectsRepository { } return { + ...(body.aggregations ? { aggregations: (body.aggregations as unknown) as A } : {}), page, per_page: perPage, total: body.hits.total, @@ -885,7 +899,7 @@ export class SavedObjectsRepository { }) ), pit_id: body.pit_id, - } as SavedObjectsFindResponse; + } as SavedObjectsFindResponse; } /** diff --git a/src/core/server/saved_objects/service/lib/utils.ts b/src/core/server/saved_objects/service/lib/utils.ts index ebad13e5edc2..494ac6ce9fad 100644 --- a/src/core/server/saved_objects/service/lib/utils.ts +++ b/src/core/server/saved_objects/service/lib/utils.ts @@ -51,10 +51,10 @@ export class SavedObjectsUtils { /** * Creates an empty response for a find operation. This is only intended to be used by saved objects client wrappers. */ - public static createEmptyFindResponse = ({ + public static createEmptyFindResponse = ({ page = FIND_DEFAULT_PAGE, perPage = FIND_DEFAULT_PER_PAGE, - }: SavedObjectsFindOptions): SavedObjectsFindResponse => ({ + }: SavedObjectsFindOptions): SavedObjectsFindResponse => ({ page, per_page: perPage, total: 0, diff --git a/src/core/server/saved_objects/service/saved_objects_client.ts b/src/core/server/saved_objects/service/saved_objects_client.ts index 9a0ccb88d355..12451ace0283 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.ts @@ -173,7 +173,8 @@ export interface SavedObjectsFindResult extends SavedObject { * * @public */ -export interface SavedObjectsFindResponse { +export interface SavedObjectsFindResponse { + aggregations?: A; saved_objects: Array>; total: number; per_page: number; @@ -463,7 +464,9 @@ export class SavedObjectsClient { * * @param options */ - async find(options: SavedObjectsFindOptions): Promise> { + async find( + options: SavedObjectsFindOptions + ): Promise> { return await this._repository.find(options); } diff --git a/src/core/server/saved_objects/types.ts b/src/core/server/saved_objects/types.ts index ecda120e025d..d3bfdcc6923d 100644 --- a/src/core/server/saved_objects/types.ts +++ b/src/core/server/saved_objects/types.ts @@ -116,6 +116,28 @@ export interface SavedObjectsFindOptions { */ defaultSearchOperator?: 'AND' | 'OR'; filter?: string | KueryNode; + /** + * A record of aggregations to perform. + * The API currently only supports a limited set of metrics and bucket aggregation types. + * Additional aggregation types can be contributed to Core. + * + * @example + * Aggregating on SO attribute field + * ```ts + * const aggs = { latest_version: { max: { field: 'dashboard.attributes.version' } } }; + * return client.find({ type: 'dashboard', aggs }) + * ``` + * + * @example + * Aggregating on SO root field + * ```ts + * const aggs = { latest_update: { max: { field: 'dashboard.updated_at' } } }; + * return client.find({ type: 'dashboard', aggs }) + * ``` + * + * @alpha + */ + aggs?: Record; 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 diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 05af684053f3..e8f9dab43575 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -2244,7 +2244,7 @@ export class SavedObjectsClient { static errors: typeof SavedObjectsErrorHelpers; // (undocumented) errors: typeof SavedObjectsErrorHelpers; - find(options: SavedObjectsFindOptions): Promise>; + find(options: SavedObjectsFindOptions): Promise>; get(type: string, id: string, options?: SavedObjectsBaseOptions): Promise>; openPointInTimeForType(type: string | string[], options?: SavedObjectsOpenPointInTimeOptions): Promise; removeReferencesTo(type: string, id: string, options?: SavedObjectsRemoveReferencesToOptions): Promise; @@ -2501,6 +2501,8 @@ export type SavedObjectsFieldMapping = SavedObjectsCoreFieldMapping | SavedObjec // @public (undocumented) export interface SavedObjectsFindOptions { + // @alpha + aggs?: Record; defaultSearchOperator?: 'AND' | 'OR'; fields?: string[]; // Warning: (ae-forgotten-export) The symbol "KueryNode" needs to be exported by the entry point index.d.ts @@ -2539,7 +2541,9 @@ export interface SavedObjectsFindOptionsReference { } // @public -export interface SavedObjectsFindResponse { +export interface SavedObjectsFindResponse { + // (undocumented) + aggregations?: A; // (undocumented) page: number; // (undocumented) @@ -2849,7 +2853,7 @@ export class SavedObjectsRepository { deleteByNamespace(namespace: string, options?: SavedObjectsDeleteByNamespaceOptions): Promise; deleteFromNamespaces(type: string, id: string, namespaces: string[], options?: SavedObjectsDeleteFromNamespacesOptions): Promise; // (undocumented) - find(options: SavedObjectsFindOptions): Promise>; + find(options: SavedObjectsFindOptions): Promise>; get(type: string, id: string, options?: SavedObjectsBaseOptions): Promise>; incrementCounter(type: string, id: string, counterFields: Array, options?: SavedObjectsIncrementCounterOptions): Promise>; openPointInTimeForType(type: string | string[], { keepAlive, preference }?: SavedObjectsOpenPointInTimeOptions): Promise; @@ -2970,7 +2974,7 @@ export interface SavedObjectsUpdateResponse extends Omit({ page, perPage, }: SavedObjectsFindOptions) => SavedObjectsFindResponse; + static createEmptyFindResponse: ({ page, perPage, }: SavedObjectsFindOptions) => SavedObjectsFindResponse; static generateId(): string; static isRandomId(id: string | undefined): boolean; static namespaceIdToString: (namespace?: string | undefined) => string; diff --git a/src/plugins/telemetry_collection_manager/server/telemetry_saved_objects_client.ts b/src/plugins/telemetry_collection_manager/server/telemetry_saved_objects_client.ts index d639b053565d..01d89c573115 100644 --- a/src/plugins/telemetry_collection_manager/server/telemetry_saved_objects_client.ts +++ b/src/plugins/telemetry_collection_manager/server/telemetry_saved_objects_client.ts @@ -17,7 +17,9 @@ export class TelemetrySavedObjectsClient extends SavedObjectsClient { * Find the SavedObjects matching the search query in all the Spaces by default * @param options */ - async find(options: SavedObjectsFindOptions): Promise> { + async find( + options: SavedObjectsFindOptions + ): Promise> { return super.find({ namespaces: ['*'], ...options }); } } diff --git a/test/api_integration/apis/saved_objects/find.ts b/test/api_integration/apis/saved_objects/find.ts index 28c38ca9e0de..a01562861e60 100644 --- a/test/api_integration/apis/saved_objects/find.ts +++ b/test/api_integration/apis/saved_objects/find.ts @@ -293,6 +293,75 @@ export default function ({ getService }: FtrProviderContext) { })); }); + describe('using aggregations', () => { + it('should return 200 with valid response for a valid aggregation', async () => + await supertest + .get( + `/api/saved_objects/_find?type=visualization&per_page=0&aggs=${encodeURIComponent( + JSON.stringify({ + type_count: { max: { field: 'visualization.attributes.version' } }, + }) + )}` + ) + .expect(200) + .then((resp) => { + expect(resp.body).to.eql({ + aggregations: { + type_count: { + value: 1, + }, + }, + page: 1, + per_page: 0, + saved_objects: [], + total: 1, + }); + })); + + it('should return a 400 when referencing an invalid SO attribute', async () => + await supertest + .get( + `/api/saved_objects/_find?type=visualization&per_page=0&aggs=${encodeURIComponent( + JSON.stringify({ + type_count: { max: { field: 'dashboard.attributes.version' } }, + }) + )}` + ) + .expect(400) + .then((resp) => { + expect(resp.body).to.eql({ + error: 'Bad Request', + message: + 'Invalid aggregation: [type_count.max.field] Invalid attribute path: dashboard.attributes.version: Bad Request', + statusCode: 400, + }); + })); + + it('should return a 400 when using a forbidden aggregation option', async () => + await supertest + .get( + `/api/saved_objects/_find?type=visualization&per_page=0&aggs=${encodeURIComponent( + JSON.stringify({ + type_count: { + max: { + field: 'visualization.attributes.version', + script: 'Bad script is bad', + }, + }, + }) + )}` + ) + .expect(400) + .then((resp) => { + expect(resp.body).to.eql({ + error: 'Bad Request', + message: + 'Invalid aggregation: [type_count.max.script]: definition for this key is missing: Bad Request', + statusCode: 400, + }); + })); + }); + describe('`has_reference` and `has_reference_operator` parameters', () => { before(() => esArchiver.load('saved_objects/references')); after(() => esArchiver.unload('saved_objects/references')); diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts index 88a89af6be3d..9b699d6ce007 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts @@ -162,9 +162,9 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon return await this.options.baseClient.delete(type, id, options); } - public async find(options: SavedObjectsFindOptions) { + public async find(options: SavedObjectsFindOptions) { return await this.handleEncryptedAttributesInBulkResponse( - await this.options.baseClient.find(options), + await this.options.baseClient.find(options), undefined ); } 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 8378cc4d848c..d876175a05fe 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 @@ -213,7 +213,7 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra return await this.baseClient.delete(type, id, options); } - public async find(options: SavedObjectsFindOptions) { + public async find(options: SavedObjectsFindOptions) { if ( this.getSpacesService() == null && Array.isArray(options.namespaces) && @@ -245,7 +245,7 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra error: new Error(status), }) ); - return SavedObjectsUtils.createEmptyFindResponse(options); + return SavedObjectsUtils.createEmptyFindResponse(options); } const typeToNamespacesMap = Array.from(typeMap).reduce>( @@ -254,7 +254,7 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra new Map() ); - const response = await this.baseClient.find({ + const response = await this.baseClient.find({ ...options, typeToNamespacesMap: undefined, // if the user is fully authorized, use `undefined` as the typeToNamespacesMap to prevent privilege escalation ...(status === 'partially_authorized' && { typeToNamespacesMap, type: '', namespaces: [] }), // the repository requires that `type` and `namespaces` must be empty if `typeToNamespacesMap` is defined diff --git a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts index c544e2f46f05..4254615ac7d5 100644 --- a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts +++ b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts @@ -171,7 +171,7 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract { * @property {object} [options.hasReference] - { type, id } * @returns {promise} - { saved_objects: [{ id, type, version, attributes }], total, per_page, page } */ - public async find(options: SavedObjectsFindOptions) { + public async find(options: SavedObjectsFindOptions) { throwErrorIfNamespaceSpecified(options); let namespaces = options.namespaces; @@ -187,12 +187,12 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract { } if (namespaces.length === 0) { // return empty response, since the user is unauthorized in this space (or these spaces), but we don't return forbidden errors for `find` operations - return SavedObjectsUtils.createEmptyFindResponse(options); + return SavedObjectsUtils.createEmptyFindResponse(options); } } catch (err) { if (Boom.isBoom(err) && err.output.payload.statusCode === 403) { // return empty response, since the user is unauthorized in any space, but we don't return forbidden errors for `find` operations - return SavedObjectsUtils.createEmptyFindResponse(options); + return SavedObjectsUtils.createEmptyFindResponse(options); } throw err; } @@ -200,7 +200,7 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract { namespaces = [this.spaceId]; } - return await this.client.find({ + return await this.client.find({ ...options, type: (options.type ? coerceToArray(options.type) : this.types).filter( (type) => type !== 'space'