diff --git a/docs/api/saved-objects/find.asciidoc b/docs/api/saved-objects/find.asciidoc
index c43b58d3aa9899..f04aeb84206207 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 ddd8b207e3d785..fc9652b96450ff 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 6e53b169b8bed3..1ec756f8d743d1 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 00000000000000..14401b02f25c74
--- /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 7d75878041264d..6f2276194f054f 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 9a4c3df5d2d920..56d76125108d1d 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 00000000000000..17a899f4c8280e
--- /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 fd56e8ce40e241..8176baf44acbd3 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 d3e93e7af2aa07..5c823b7567918b 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 40e865cb02ce8e..23cbebf22aa214 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 8c787364c4cbe0..0148621e757b79 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 8c1753c2cababc..18133ebec33533 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 44466025de7e31..782ffa68970484 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 6ba23747cf3745..d21039db30e5ff 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 00000000000000..1508cab69a0486
--- /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 00000000000000..7967fad0185fbb
--- /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 00000000000000..c05ae67cd2164e
--- /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 00000000000000..f71d3e8daea9d2
--- /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 00000000000000..8a7c1c3719eb0f
--- /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 00000000000000..a2fd392183132f
--- /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 00000000000000..25c3aea474ecec
--- /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 00000000000000..f817497e3759e9
--- /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 b50326627cf097..956a60b23809d3 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 688b7ad96e8ed5..b3bcef9a62e130 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 7c719ac56a8351..c0e2cdc3333633 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 ebad13e5edc251..494ac6ce9fad5e 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 9a0ccb88d35558..12451ace028364 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 ecda120e025d89..d3bfdcc6923dcf 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 05af684053f391..e8f9dab435754a 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 d639b053565d1f..01d89c57311588 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 28c38ca9e0ded1..a01562861e606c 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 88a89af6be3d0e..9b699d6ce007ce 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 8378cc4d848cf2..d876175a05fe8e 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