diff --git a/x-pack/plugins/alerting/server/rules_client/rules_client.ts b/x-pack/plugins/alerting/server/rules_client/rules_client.ts index 86f0d3becdce7..1512959384ac9 100644 --- a/x-pack/plugins/alerting/server/rules_client/rules_client.ts +++ b/x-pack/plugins/alerting/server/rules_client/rules_client.ts @@ -594,7 +594,7 @@ export class RulesClient { page: 1, per_page: 10000, start: parsedDateStart.toISOString(), - sort_order: 'desc', + sort: [{ sort_field: '@timestamp', sort_order: 'desc' }], end: dateNow.toISOString(), }, rule.legacyId !== null ? [rule.legacyId] : undefined @@ -606,7 +606,7 @@ export class RulesClient { page: 1, per_page: numberOfExecutions ?? 60, filter: 'event.provider: alerting AND event.action:execute', - sort_order: 'desc', + sort: [{ sort_field: '@timestamp', sort_order: 'desc' }], end: dateNow.toISOString(), }, rule.legacyId !== null ? [rule.legacyId] : undefined diff --git a/x-pack/plugins/alerting/server/rules_client/tests/get_alert_summary.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/get_alert_summary.test.ts index 4e6f627dcd4a6..fcf90bc350362 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/get_alert_summary.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/get_alert_summary.test.ts @@ -223,7 +223,12 @@ describe('getAlertSummary()', () => { "end": "2019-02-12T21:01:22.479Z", "page": 1, "per_page": 10000, - "sort_order": "desc", + "sort": Array [ + Object { + "sort_field": "@timestamp", + "sort_order": "desc", + }, + ], "start": "2019-02-12T21:00:22.479Z", }, undefined, @@ -260,7 +265,12 @@ describe('getAlertSummary()', () => { "end": "2019-02-12T21:01:22.479Z", "page": 1, "per_page": 10000, - "sort_order": "desc", + "sort": Array [ + Object { + "sort_field": "@timestamp", + "sort_order": "desc", + }, + ], "start": "2019-02-12T21:00:22.479Z", }, Array [ diff --git a/x-pack/plugins/event_log/common/index.ts b/x-pack/plugins/event_log/common/index.ts index 79ecd47628712..5910dbe2c5ad7 100644 --- a/x-pack/plugins/event_log/common/index.ts +++ b/x-pack/plugins/event_log/common/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export const BASE_EVENT_LOG_API_PATH = '/api/event_log'; +export const BASE_EVENT_LOG_API_PATH = '/internal/event_log'; diff --git a/x-pack/plugins/event_log/server/es/cluster_client_adapter.mock.ts b/x-pack/plugins/event_log/server/es/cluster_client_adapter.mock.ts index 667512ea13f65..53a1b9501b432 100644 --- a/x-pack/plugins/event_log/server/es/cluster_client_adapter.mock.ts +++ b/x-pack/plugins/event_log/server/es/cluster_client_adapter.mock.ts @@ -24,6 +24,7 @@ const createClusterClientMock = () => { getExistingIndexAliases: jest.fn(), setIndexAliasToHidden: jest.fn(), queryEventsBySavedObjects: jest.fn(), + aggregateEventsBySavedObjects: jest.fn(), shutdown: jest.fn(), }; return mock; diff --git a/x-pack/plugins/event_log/server/es/cluster_client_adapter.test.ts b/x-pack/plugins/event_log/server/es/cluster_client_adapter.test.ts index 22898ac54db5a..56a708ef51b67 100644 --- a/x-pack/plugins/event_log/server/es/cluster_client_adapter.test.ts +++ b/x-pack/plugins/event_log/server/es/cluster_client_adapter.test.ts @@ -10,10 +10,13 @@ import { ClusterClientAdapter, IClusterClientAdapter, EVENT_BUFFER_LENGTH, + getQueryBody, + FindEventsOptionsBySavedObjectFilter, + AggregateEventsOptionsBySavedObjectFilter, } from './cluster_client_adapter'; -import { findOptionsSchema } from '../event_log_client'; +import { AggregateOptionsType, queryOptionsSchema } from '../event_log_client'; import { delay } from '../lib/delay'; -import { times } from 'lodash'; +import { pick, times } from 'lodash'; import type * as estypes from '@elastic/elasticsearch/lib/api/types'; type MockedLogger = ReturnType; @@ -567,10 +570,93 @@ describe('createIndex', () => { }); describe('queryEventsBySavedObject', () => { - const DEFAULT_OPTIONS = findOptionsSchema.validate({}); + const DEFAULT_OPTIONS = queryOptionsSchema.validate({}); - test('should call cluster with proper arguments with non-default namespace', async () => { + test('should call cluster with correct options', async () => { clusterClient.search.mockResponse({ + hits: { + hits: [{ _index: 'index-name-00001', _id: '1', _source: { foo: 'bar' } }], + total: { relation: 'eq', value: 1 }, + }, + took: 0, + timed_out: false, + _shards: { + failed: 0, + successful: 0, + total: 0, + skipped: 0, + }, + }); + const options = { + index: 'index-name', + namespace: 'namespace', + type: 'saved-object-type', + ids: ['saved-object-id'], + findOptions: { + ...DEFAULT_OPTIONS, + page: 3, + per_page: 6, + sort: [ + { sort_field: '@timestamp', sort_order: 'asc' }, + { sort_field: 'event.end', sort_order: 'desc' }, + ], + }, + }; + const result = await clusterClientAdapter.queryEventsBySavedObjects(options); + + const [query] = clusterClient.search.mock.calls[0]; + expect(query).toEqual({ + index: 'index-name', + track_total_hits: true, + body: { + size: 6, + from: 12, + query: getQueryBody(logger, options, pick(options.findOptions, ['start', 'end', 'filter'])), + sort: [{ '@timestamp': { order: 'asc' } }, { 'event.end': { order: 'desc' } }], + }, + }); + expect(result).toEqual({ + page: 3, + per_page: 6, + total: 1, + data: [{ foo: 'bar' }], + }); + }); +}); + +describe('aggregateEventsBySavedObject', () => { + const DEFAULT_OPTIONS = { + ...queryOptionsSchema.validate({}), + aggs: { + genericAgg: { + term: { + field: 'event.action', + size: 10, + }, + }, + }, + }; + + test('should call cluster with correct options', async () => { + clusterClient.search.mockResponse({ + aggregations: { + genericAgg: { + buckets: [ + { + key: 'execute', + doc_count: 10, + }, + { + key: 'execute-start', + doc_count: 10, + }, + { + key: 'new-instance', + doc_count: 2, + }, + ], + }, + }, hits: { hits: [], total: { relation: 'eq', value: 0 }, @@ -584,85 +670,130 @@ describe('queryEventsBySavedObject', () => { skipped: 0, }, }); - await clusterClientAdapter.queryEventsBySavedObjects({ + const options: AggregateEventsOptionsBySavedObjectFilter = { index: 'index-name', namespace: 'namespace', type: 'saved-object-type', ids: ['saved-object-id'], - findOptions: DEFAULT_OPTIONS, - }); + aggregateOptions: DEFAULT_OPTIONS as AggregateOptionsType, + }; + const result = await clusterClientAdapter.aggregateEventsBySavedObjects(options); const [query] = clusterClient.search.mock.calls[0]; - expect(query).toMatchInlineSnapshot( - { - body: { - from: 0, - query: { - bool: { - filter: [], - must: [ - { - nested: { - path: 'kibana.saved_objects', - query: { + expect(query).toEqual({ + index: 'index-name', + body: { + size: 0, + query: getQueryBody( + logger, + options, + pick(options.aggregateOptions, ['start', 'end', 'filter']) + ), + aggs: { + genericAgg: { + term: { + field: 'event.action', + size: 10, + }, + }, + }, + }, + }); + expect(result).toEqual({ + aggregations: { + genericAgg: { + buckets: [ + { + key: 'execute', + doc_count: 10, + }, + { + key: 'execute-start', + doc_count: 10, + }, + { + key: 'new-instance', + doc_count: 2, + }, + ], + }, + }, + }); + }); +}); + +describe('getQueryBody', () => { + const options = { + index: 'index-name', + namespace: undefined, + type: 'saved-object-type', + ids: ['saved-object-id'], + }; + test('should correctly build query with namespace filter when namespace is undefined', () => { + expect(getQueryBody(logger, options as FindEventsOptionsBySavedObjectFilter, {})).toEqual({ + bool: { + must: [ + { + nested: { + path: 'kibana.saved_objects', + query: { + bool: { + must: [ + { + term: { + 'kibana.saved_objects.rel': { + value: 'primary', + }, + }, + }, + { + term: { + 'kibana.saved_objects.type': { + value: 'saved-object-type', + }, + }, + }, + { bool: { - must: [ - { - term: { - 'kibana.saved_objects.rel': { - value: 'primary', - }, - }, - }, - { - term: { - 'kibana.saved_objects.type': { - value: 'saved-object-type', - }, - }, - }, - { - term: { - 'kibana.saved_objects.namespace': { - value: 'namespace', - }, - }, + must_not: { + exists: { + field: 'kibana.saved_objects.namespace', }, - ], + }, }, }, - }, + ], }, + }, + }, + }, + { + bool: { + should: [ { bool: { - should: [ + must: [ { - bool: { - must: [ - { - nested: { - path: 'kibana.saved_objects', - query: { - bool: { - must: [ - { - terms: { - 'kibana.saved_objects.id': ['saved-object-id'], - }, - }, - ], + nested: { + path: 'kibana.saved_objects', + query: { + bool: { + must: [ + { + terms: { + 'kibana.saved_objects.id': ['saved-object-id'], }, }, - }, - }, - { - range: { - 'kibana.version': { - gte: '8.0.0', - }, - }, + ], }, - ], + }, + }, + }, + { + range: { + 'kibana.version': { + gte: '8.0.0', + }, }, }, ], @@ -671,90 +802,80 @@ describe('queryEventsBySavedObject', () => { ], }, }, - size: 10, - sort: [ - { - '@timestamp': { - order: 'asc', - }, - }, - ], - }, - index: 'index-name', - track_total_hits: true, + ], }, - ` - Object { - "body": Object { - "from": 0, - "query": Object { - "bool": Object { - "filter": Array [], - "must": Array [ - Object { - "nested": Object { - "path": "kibana.saved_objects", - "query": Object { - "bool": Object { - "must": Array [ - Object { - "term": Object { - "kibana.saved_objects.rel": Object { - "value": "primary", - }, - }, - }, - Object { - "term": Object { - "kibana.saved_objects.type": Object { - "value": "saved-object-type", - }, - }, - }, - Object { - "term": Object { - "kibana.saved_objects.namespace": Object { - "value": "namespace", - }, - }, - }, - ], + }); + }); + + test('should correctly build query with namespace filter when namespace is specified', () => { + expect( + getQueryBody( + logger, + { ...options, namespace: 'namespace' } as FindEventsOptionsBySavedObjectFilter, + {} + ) + ).toEqual({ + bool: { + must: [ + { + nested: { + path: 'kibana.saved_objects', + query: { + bool: { + must: [ + { + term: { + 'kibana.saved_objects.rel': { + value: 'primary', + }, }, }, - }, + { + term: { + 'kibana.saved_objects.type': { + value: 'saved-object-type', + }, + }, + }, + { + term: { + 'kibana.saved_objects.namespace': { + value: 'namespace', + }, + }, + }, + ], }, - Object { - "bool": Object { - "should": Array [ - Object { - "bool": Object { - "must": Array [ - Object { - "nested": Object { - "path": "kibana.saved_objects", - "query": Object { - "bool": Object { - "must": Array [ - Object { - "terms": Object { - "kibana.saved_objects.id": Array [ - "saved-object-id", - ], - }, - }, - ], + }, + }, + }, + { + bool: { + should: [ + { + bool: { + must: [ + { + nested: { + path: 'kibana.saved_objects', + query: { + bool: { + must: [ + { + terms: { + 'kibana.saved_objects.id': ['saved-object-id'], }, }, - }, - }, - Object { - "range": Object { - "kibana.version": Object { - "gte": "8.0.0", - }, - }, + ], }, - ], + }, + }, + }, + { + range: { + 'kibana.version': { + gte: '8.0.0', + }, }, }, ], @@ -763,117 +884,40 @@ describe('queryEventsBySavedObject', () => { ], }, }, - "size": 10, - "sort": Array [ - Object { - "@timestamp": Object { - "order": "asc", - }, - }, - ], - }, - "index": "index-name", - "track_total_hits": true, - } - ` - ); - }); - - test('should call cluster with proper arguments with default namespace', async () => { - clusterClient.search.mockResponse({ - hits: { - hits: [], - total: { relation: 'eq', value: 0 }, - }, - took: 0, - timed_out: false, - _shards: { - failed: 0, - successful: 0, - total: 0, - skipped: 0, + ], }, }); - await clusterClientAdapter.queryEventsBySavedObjects({ - index: 'index-name', - namespace: undefined, - type: 'saved-object-type', - ids: ['saved-object-id'], - findOptions: DEFAULT_OPTIONS, - }); + }); - const [query] = clusterClient.search.mock.calls[0]; - expect(query).toMatchObject({ - body: { - from: 0, - query: { + test('should correctly build query when filter is specified', () => { + expect( + getQueryBody(logger, options as FindEventsOptionsBySavedObjectFilter, { + filter: 'event.provider: alerting AND event.action:execute', + }) + ).toEqual({ + bool: { + filter: { bool: { - filter: [], - must: [ + filter: [ { - nested: { - path: 'kibana.saved_objects', - query: { - bool: { - must: [ - { - term: { - 'kibana.saved_objects.rel': { - value: 'primary', - }, - }, - }, - { - term: { - 'kibana.saved_objects.type': { - value: 'saved-object-type', - }, - }, - }, - { - bool: { - must_not: { - exists: { - field: 'kibana.saved_objects.namespace', - }, - }, - }, - }, - ], + bool: { + minimum_should_match: 1, + should: [ + { + match: { + 'event.provider': 'alerting', + }, }, - }, + ], }, }, { bool: { + minimum_should_match: 1, should: [ { - bool: { - must: [ - { - nested: { - path: 'kibana.saved_objects', - query: { - bool: { - must: [ - { - terms: { - 'kibana.saved_objects.id': ['saved-object-id'], - }, - }, - ], - }, - }, - }, - }, - { - range: { - 'kibana.version': { - gte: '8.0.0', - }, - }, - }, - ], + match: { + 'event.action': 'execute', }, }, ], @@ -882,352 +926,481 @@ describe('queryEventsBySavedObject', () => { ], }, }, - size: 10, - sort: [ + must: [ { - '@timestamp': { - order: 'asc', + nested: { + path: 'kibana.saved_objects', + query: { + bool: { + must: [ + { + term: { + 'kibana.saved_objects.rel': { + value: 'primary', + }, + }, + }, + { + term: { + 'kibana.saved_objects.type': { + value: 'saved-object-type', + }, + }, + }, + { + bool: { + must_not: { + exists: { + field: 'kibana.saved_objects.namespace', + }, + }, + }, + }, + ], + }, + }, }, }, - ], - }, - index: 'index-name', - track_total_hits: true, - }); - }); - - test('should call cluster with sort', async () => { - clusterClient.search.mockResponse({ - hits: { - hits: [], - total: { relation: 'eq', value: 0 }, - }, - took: 0, - timed_out: false, - _shards: { - failed: 0, - successful: 0, - total: 0, - skipped: 0, - }, - }); - await clusterClientAdapter.queryEventsBySavedObjects({ - index: 'index-name', - namespace: 'namespace', - type: 'saved-object-type', - ids: ['saved-object-id'], - findOptions: { ...DEFAULT_OPTIONS, sort_field: 'event.end', sort_order: 'desc' }, - }); - - const [query] = clusterClient.search.mock.calls[0]; - expect(query).toMatchObject({ - index: 'index-name', - body: { - sort: [{ 'event.end': { order: 'desc' } }], + { + bool: { + should: [ + { + bool: { + must: [ + { + nested: { + path: 'kibana.saved_objects', + query: { + bool: { + must: [ + { + terms: { + 'kibana.saved_objects.id': ['saved-object-id'], + }, + }, + ], + }, + }, + }, + }, + { + range: { + 'kibana.version': { + gte: '8.0.0', + }, + }, + }, + ], + }, + }, + ], + }, + }, + ], }, }); }); - test('supports open ended date', async () => { - clusterClient.search.mockResponse({ - hits: { - hits: [], - total: { relation: 'eq', value: 0 }, - }, - took: 0, - timed_out: false, - _shards: { - failed: 0, - successful: 0, - total: 0, - skipped: 0, - }, - }); - - const start = '2020-07-08T00:52:28.350Z'; - - await clusterClientAdapter.queryEventsBySavedObjects({ - index: 'index-name', - namespace: 'namespace', - type: 'saved-object-type', - ids: ['saved-object-id'], - findOptions: { ...DEFAULT_OPTIONS, start }, - }); - - const [query] = clusterClient.search.mock.calls[0]; - expect(query).toMatchObject({ - body: { - from: 0, - query: { - bool: { - filter: [], - must: [ - { - nested: { - path: 'kibana.saved_objects', - query: { - bool: { - must: [ - { - term: { - 'kibana.saved_objects.rel': { - value: 'primary', - }, + test('should correctly build query when legacyIds are specified', () => { + expect( + getQueryBody( + logger, + { ...options, legacyIds: ['legacy-id-1'] } as FindEventsOptionsBySavedObjectFilter, + {} + ) + ).toEqual({ + bool: { + must: [ + { + nested: { + path: 'kibana.saved_objects', + query: { + bool: { + must: [ + { + term: { + 'kibana.saved_objects.rel': { + value: 'primary', + }, + }, + }, + { + term: { + 'kibana.saved_objects.type': { + value: 'saved-object-type', + }, + }, + }, + { + bool: { + must_not: { + exists: { + field: 'kibana.saved_objects.namespace', }, }, - { - term: { - 'kibana.saved_objects.type': { - value: 'saved-object-type', + }, + }, + ], + }, + }, + }, + }, + { + bool: { + should: [ + { + bool: { + must: [ + { + nested: { + path: 'kibana.saved_objects', + query: { + bool: { + must: [ + { + terms: { + 'kibana.saved_objects.id': ['saved-object-id'], + }, + }, + ], }, }, }, - { - term: { - 'kibana.saved_objects.namespace': { - value: 'namespace', - }, + }, + { + range: { + 'kibana.version': { + gte: '8.0.0', }, }, - ], - }, + }, + ], }, }, - }, - { - bool: { - should: [ - { - bool: { - must: [ - { - nested: { - path: 'kibana.saved_objects', - query: { - bool: { - must: [ - { - terms: { - 'kibana.saved_objects.id': ['saved-object-id'], - }, - }, - ], + { + bool: { + must: [ + { + nested: { + path: 'kibana.saved_objects', + query: { + bool: { + must: [ + { + terms: { + 'kibana.saved_objects.id': ['legacy-id-1'], + }, }, - }, + ], }, }, - { - range: { - 'kibana.version': { - gte: '8.0.0', + }, + }, + { + bool: { + should: [ + { + range: { + 'kibana.version': { + lt: '8.0.0', + }, + }, + }, + { + bool: { + must_not: { + exists: { + field: 'kibana.version', + }, + }, }, }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }); + }); + + test('should correctly build query when start is specified', () => { + expect( + getQueryBody(logger, options as FindEventsOptionsBySavedObjectFilter, { + start: '2020-07-08T00:52:28.350Z', + }) + ).toEqual({ + bool: { + must: [ + { + nested: { + path: 'kibana.saved_objects', + query: { + bool: { + must: [ + { + term: { + 'kibana.saved_objects.rel': { + value: 'primary', + }, + }, + }, + { + term: { + 'kibana.saved_objects.type': { + value: 'saved-object-type', + }, + }, + }, + { + bool: { + must_not: { + exists: { + field: 'kibana.saved_objects.namespace', }, - ], + }, }, }, ], }, }, - { - range: { - '@timestamp': { - gte: '2020-07-08T00:52:28.350Z', + }, + }, + { + bool: { + should: [ + { + bool: { + must: [ + { + nested: { + path: 'kibana.saved_objects', + query: { + bool: { + must: [ + { + terms: { + 'kibana.saved_objects.id': ['saved-object-id'], + }, + }, + ], + }, + }, + }, + }, + { + range: { + 'kibana.version': { + gte: '8.0.0', + }, + }, + }, + ], }, }, - }, - ], + ], + }, }, - }, - size: 10, - sort: [ { - '@timestamp': { - order: 'asc', + range: { + '@timestamp': { + gte: '2020-07-08T00:52:28.350Z', + }, }, }, ], }, - index: 'index-name', - track_total_hits: true, }); }); - test('supports optional date range', async () => { - clusterClient.search.mockResponse({ - hits: { - hits: [], - total: { relation: 'eq', value: 0 }, - }, - took: 0, - timed_out: false, - _shards: { - failed: 0, - successful: 0, - total: 0, - skipped: 0, - }, - }); - - const start = '2020-07-08T00:52:28.350Z'; - const end = '2020-07-08T00:00:00.000Z'; - - await clusterClientAdapter.queryEventsBySavedObjects({ - index: 'index-name', - namespace: 'namespace', - type: 'saved-object-type', - ids: ['saved-object-id'], - findOptions: { ...DEFAULT_OPTIONS, start, end }, - legacyIds: ['legacy-id'], - }); - - const [query] = clusterClient.search.mock.calls[0]; - expect(query).toMatchObject({ - body: { - from: 0, - query: { - bool: { - filter: [], - must: [ - { - nested: { - path: 'kibana.saved_objects', - query: { - bool: { - must: [ - { - term: { - 'kibana.saved_objects.rel': { - value: 'primary', - }, + test('should correctly build query when end is specified', () => { + expect( + getQueryBody(logger, options as FindEventsOptionsBySavedObjectFilter, { + end: '2020-07-10T00:52:28.350Z', + }) + ).toEqual({ + bool: { + must: [ + { + nested: { + path: 'kibana.saved_objects', + query: { + bool: { + must: [ + { + term: { + 'kibana.saved_objects.rel': { + value: 'primary', + }, + }, + }, + { + term: { + 'kibana.saved_objects.type': { + value: 'saved-object-type', + }, + }, + }, + { + bool: { + must_not: { + exists: { + field: 'kibana.saved_objects.namespace', }, }, - { - term: { - 'kibana.saved_objects.type': { - value: 'saved-object-type', + }, + }, + ], + }, + }, + }, + }, + { + bool: { + should: [ + { + bool: { + must: [ + { + nested: { + path: 'kibana.saved_objects', + query: { + bool: { + must: [ + { + terms: { + 'kibana.saved_objects.id': ['saved-object-id'], + }, + }, + ], }, }, }, - { - term: { - 'kibana.saved_objects.namespace': { - value: 'namespace', - }, + }, + { + range: { + 'kibana.version': { + gte: '8.0.0', }, }, - ], - }, + }, + ], }, }, + ], + }, + }, + { + range: { + '@timestamp': { + lte: '2020-07-10T00:52:28.350Z', }, - { + }, + }, + ], + }, + }); + }); + + test('should correctly build query when start and end are specified', () => { + expect( + getQueryBody(logger, options as FindEventsOptionsBySavedObjectFilter, { + start: '2020-07-08T00:52:28.350Z', + end: '2020-07-10T00:52:28.350Z', + }) + ).toEqual({ + bool: { + must: [ + { + nested: { + path: 'kibana.saved_objects', + query: { bool: { - should: [ + must: [ { - bool: { - must: [ - { - nested: { - path: 'kibana.saved_objects', - query: { - bool: { - must: [ - { - terms: { - 'kibana.saved_objects.id': ['saved-object-id'], - }, - }, - ], - }, - }, - }, - }, - { - range: { - 'kibana.version': { - gte: '8.0.0', - }, - }, - }, - ], + term: { + 'kibana.saved_objects.rel': { + value: 'primary', + }, + }, + }, + { + term: { + 'kibana.saved_objects.type': { + value: 'saved-object-type', + }, }, }, { bool: { - must: [ - { - nested: { - path: 'kibana.saved_objects', - query: { - bool: { - must: [ - { - terms: { - 'kibana.saved_objects.id': ['legacy-id'], - }, - }, - ], - }, - }, - }, + must_not: { + exists: { + field: 'kibana.saved_objects.namespace', }, - { + }, + }, + }, + ], + }, + }, + }, + }, + { + bool: { + should: [ + { + bool: { + must: [ + { + nested: { + path: 'kibana.saved_objects', + query: { bool: { - should: [ + must: [ { - range: { - 'kibana.version': { - lt: '8.0.0', - }, - }, - }, - { - bool: { - must_not: { - exists: { - field: 'kibana.version', - }, - }, + terms: { + 'kibana.saved_objects.id': ['saved-object-id'], }, }, ], }, }, - ], + }, }, - }, - ], - }, - }, - { - range: { - '@timestamp': { - gte: '2020-07-08T00:52:28.350Z', - }, - }, - }, - { - range: { - '@timestamp': { - lte: '2020-07-08T00:00:00.000Z', + { + range: { + 'kibana.version': { + gte: '8.0.0', + }, + }, + }, + ], }, }, + ], + }, + }, + { + range: { + '@timestamp': { + gte: '2020-07-08T00:52:28.350Z', }, - ], + }, }, - }, - size: 10, - sort: [ { - '@timestamp': { - order: 'asc', + range: { + '@timestamp': { + lte: '2020-07-10T00:52:28.350Z', + }, }, }, ], }, - index: 'index-name', - track_total_hits: true, }); }); }); diff --git a/x-pack/plugins/event_log/server/es/cluster_client_adapter.ts b/x-pack/plugins/event_log/server/es/cluster_client_adapter.ts index bb958c3ce2b54..502e48795f0cc 100644 --- a/x-pack/plugins/event_log/server/es/cluster_client_adapter.ts +++ b/x-pack/plugins/event_log/server/es/cluster_client_adapter.ts @@ -14,7 +14,7 @@ import util from 'util'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { fromKueryExpression, toElasticsearchQuery } from '@kbn/es-query'; import { IEvent, IValidatedEvent, SAVED_OBJECT_REL_PRIMARY } from '../types'; -import { FindOptionsType } from '../event_log_client'; +import { AggregateOptionsType, FindOptionsType, QueryOptionsType } from '../event_log_client'; import { ParsedIndexAlias } from './init'; export const EVENT_BUFFER_TIME = 1000; // milliseconds @@ -47,10 +47,21 @@ interface QueryOptionsEventsBySavedObjectFilter { namespace: string | undefined; type: string; ids: string[]; - findOptions: FindOptionsType; legacyIds?: string[]; } +export type FindEventsOptionsBySavedObjectFilter = QueryOptionsEventsBySavedObjectFilter & { + findOptions: FindOptionsType; +}; + +export type AggregateEventsOptionsBySavedObjectFilter = QueryOptionsEventsBySavedObjectFilter & { + aggregateOptions: AggregateOptionsType; +}; + +export interface AggregateEventsBySavedObjectResult { + aggregations: Record | undefined; +} + // eslint-disable-next-line @typescript-eslint/no-explicit-any type AliasAny = any; @@ -327,75 +338,192 @@ export class ClusterClientAdapter { - const { index, namespace, type, ids, findOptions, legacyIds } = queryOptions; - // eslint-disable-next-line @typescript-eslint/naming-convention - const { page, per_page: perPage, start, end, sort_field, sort_order, filter } = findOptions; + const { index, type, ids, findOptions } = queryOptions; + const { page, per_page: perPage, sort } = findOptions; - const defaultNamespaceQuery = { - bool: { - must_not: { - exists: { - field: 'kibana.saved_objects.namespace', - }, - }, - }, - }; - const namedNamespaceQuery = { - term: { - 'kibana.saved_objects.namespace': { - value: namespace, - }, - }, + const esClient = await this.elasticsearchClientPromise; + + const query = getQueryBody( + this.logger, + queryOptions, + pick(queryOptions.findOptions, ['start', 'end', 'filter']) + ); + + const body: estypes.SearchRequest['body'] = { + size: perPage, + from: (page - 1) * perPage, + query, + ...(sort + ? { sort: sort.map((s) => ({ [s.sort_field]: { order: s.sort_order } })) as estypes.Sort } + : {}), }; - const namespaceQuery = namespace === undefined ? defaultNamespaceQuery : namedNamespaceQuery; + + try { + const { + hits: { hits, total }, + } = await esClient.search({ + index, + track_total_hits: true, + body, + }); + return { + page, + per_page: perPage, + total: isNumber(total) ? total : total!.value, + data: hits.map((hit) => hit._source), + }; + } catch (err) { + throw new Error( + `querying for Event Log by for type "${type}" and ids "${ids}" failed with: ${err.message}` + ); + } + } + + public async aggregateEventsBySavedObjects( + queryOptions: AggregateEventsOptionsBySavedObjectFilter + ): Promise { + const { index, type, ids, aggregateOptions } = queryOptions; + const { aggs } = aggregateOptions; const esClient = await this.elasticsearchClientPromise; - let dslFilterQuery: estypes.QueryDslBoolQuery['filter']; + + const query = getQueryBody( + this.logger, + queryOptions, + pick(queryOptions.aggregateOptions, ['start', 'end', 'filter']) + ); + + const body: estypes.SearchRequest['body'] = { + size: 0, + query, + aggs, + }; + try { - dslFilterQuery = filter ? toElasticsearchQuery(fromKueryExpression(filter)) : []; + const { aggregations } = await esClient.search({ + index, + body, + }); + return { + aggregations, + }; } catch (err) { - this.debug(`Invalid kuery syntax for the filter (${filter}) error:`, { + throw new Error( + `querying for Event Log by for type "${type}" and ids "${ids}" failed with: ${err.message}` + ); + } + } +} + +function getNamespaceQuery(namespace?: string) { + const defaultNamespaceQuery = { + bool: { + must_not: { + exists: { + field: 'kibana.saved_objects.namespace', + }, + }, + }, + }; + const namedNamespaceQuery = { + term: { + 'kibana.saved_objects.namespace': { + value: namespace, + }, + }, + }; + return namespace === undefined ? defaultNamespaceQuery : namedNamespaceQuery; +} + +export function getQueryBody( + logger: Logger, + opts: FindEventsOptionsBySavedObjectFilter | AggregateEventsOptionsBySavedObjectFilter, + queryOptions: QueryOptionsType +) { + const { namespace, type, ids, legacyIds } = opts; + const { start, end, filter } = queryOptions ?? {}; + + const namespaceQuery = getNamespaceQuery(namespace); + let dslFilterQuery: estypes.QueryDslBoolQuery['filter']; + try { + dslFilterQuery = filter ? toElasticsearchQuery(fromKueryExpression(filter)) : undefined; + } catch (err) { + logger.debug( + `esContext: Invalid kuery syntax for the filter (${filter}) error: ${JSON.stringify({ message: err.message, statusCode: err.statusCode, - }); - throw err; - } - const savedObjectsQueryMust: estypes.QueryDslQueryContainer[] = [ - { - term: { - 'kibana.saved_objects.rel': { - value: SAVED_OBJECT_REL_PRIMARY, - }, + })}` + ); + throw err; + } + + const savedObjectsQueryMust: estypes.QueryDslQueryContainer[] = [ + { + term: { + 'kibana.saved_objects.rel': { + value: SAVED_OBJECT_REL_PRIMARY, }, }, - { - term: { - 'kibana.saved_objects.type': { - value: type, + }, + { + term: { + 'kibana.saved_objects.type': { + value: type, + }, + }, + }, + // @ts-expect-error undefined is not assignable as QueryDslTermQuery value + namespaceQuery, + ]; + + const musts: estypes.QueryDslQueryContainer[] = [ + { + nested: { + path: 'kibana.saved_objects', + query: { + bool: { + must: reject(savedObjectsQueryMust, isUndefined), }, }, }, - // @ts-expect-error undefined is not assignable as QueryDslTermQuery value - namespaceQuery, - ]; - - const musts: estypes.QueryDslQueryContainer[] = [ - { - nested: { - path: 'kibana.saved_objects', - query: { - bool: { - must: reject(savedObjectsQueryMust, isUndefined), + }, + ]; + + const shouldQuery = []; + shouldQuery.push({ + bool: { + must: [ + { + nested: { + path: 'kibana.saved_objects', + query: { + bool: { + must: [ + { + terms: { + // default maximum of 65,536 terms, configurable by index.max_terms_count + 'kibana.saved_objects.id': ids, + }, + }, + ], + }, }, }, }, - }, - ]; - - const shouldQuery = []; + { + range: { + 'kibana.version': { + gte: LEGACY_ID_CUTOFF_VERSION, + }, + }, + }, + ], + }, + }); + if (legacyIds && legacyIds.length > 0) { shouldQuery.push({ bool: { must: [ @@ -408,7 +536,7 @@ export class ClusterClientAdapter 0) { - shouldQuery.push({ - bool: { - must: [ - { - nested: { - path: 'kibana.saved_objects', - query: { - bool: { - must: [ - { - terms: { - // default maximum of 65,536 terms, configurable by index.max_terms_count - 'kibana.saved_objects.id': legacyIds, - }, - }, - ], - }, - }, - }, - }, - { - bool: { - should: [ - { - range: { - 'kibana.version': { - lt: LEGACY_ID_CUTOFF_VERSION, - }, + bool: { + should: [ + { + range: { + 'kibana.version': { + lt: LEGACY_ID_CUTOFF_VERSION, }, }, - { - bool: { - must_not: { - exists: { - field: 'kibana.version', - }, + }, + { + bool: { + must_not: { + exists: { + field: 'kibana.version', }, }, }, - ], - }, + }, + ], }, - ], - }, - }); - } - - musts.push({ - bool: { - should: shouldQuery, + }, + ], }, }); + } - if (start) { - musts.push({ - range: { - '@timestamp': { - gte: start, - }, - }, - }); - } - if (end) { - musts.push({ - range: { - '@timestamp': { - lte: end, - }, - }, - }); - } + musts.push({ + bool: { + should: shouldQuery, + }, + }); - const body: estypes.SearchRequest['body'] = { - size: perPage, - from: (page - 1) * perPage, - sort: [{ [sort_field]: { order: sort_order } }], - query: { - bool: { - filter: dslFilterQuery, - must: reject(musts, isUndefined), + if (start) { + musts.push({ + range: { + '@timestamp': { + gte: start, }, }, - }; - - try { - const { - hits: { hits, total }, - } = await esClient.search({ - index, - track_total_hits: true, - body, - }); - return { - page, - per_page: perPage, - total: isNumber(total) ? total : total!.value, - data: hits.map((hit) => hit._source), - }; - } catch (err) { - throw new Error( - `querying for Event Log by for type "${type}" and ids "${ids}" failed with: ${err.message}` - ); - } + }); } - - private debug(message: string, object?: unknown) { - const objectString = object == null ? '' : JSON.stringify(object); - this.logger.debug(`esContext: ${message} ${objectString}`); + if (end) { + musts.push({ + range: { + '@timestamp': { + lte: end, + }, + }, + }); } + + return { + bool: { + ...(dslFilterQuery ? { filter: dslFilterQuery } : {}), + must: reject(musts, isUndefined), + }, + }; } diff --git a/x-pack/plugins/event_log/server/event_log_client.mock.ts b/x-pack/plugins/event_log/server/event_log_client.mock.ts index f3071b4a1d074..7129bb9513856 100644 --- a/x-pack/plugins/event_log/server/event_log_client.mock.ts +++ b/x-pack/plugins/event_log/server/event_log_client.mock.ts @@ -10,6 +10,7 @@ import { IEventLogClient } from './types'; const createEventLogClientMock = () => { const mock: jest.Mocked = { findEventsBySavedObjectIds: jest.fn(), + aggregateEventsBySavedObjectIds: jest.fn(), }; return mock; }; diff --git a/x-pack/plugins/event_log/server/event_log_client.test.ts b/x-pack/plugins/event_log/server/event_log_client.test.ts index 0acb53e93b81a..9ee75dd97ed19 100644 --- a/x-pack/plugins/event_log/server/event_log_client.test.ts +++ b/x-pack/plugins/event_log/server/event_log_client.test.ts @@ -7,101 +7,82 @@ import { KibanaRequest } from 'src/core/server'; import { EventLogClient } from './event_log_client'; +import { EsContext } from './es'; import { contextMock } from './es/context.mock'; import { merge } from 'lodash'; import moment from 'moment'; +import { IClusterClientAdapter } from './es/cluster_client_adapter'; + +const expectedSavedObject = { + id: 'saved-object-id', + type: 'saved-object-type', + attributes: {}, + references: [], +}; + +const expectedEvents = [ + fakeEvent({ + kibana: { + saved_objects: [ + { + id: 'saved-object-id', + type: 'saved-object-type', + }, + { + type: 'action', + id: '1', + }, + ], + }, + }), + fakeEvent({ + kibana: { + saved_objects: [ + { + id: 'saved-object-id', + type: 'saved-object-type', + }, + { + type: 'action', + id: '2', + }, + ], + }, + }), +]; describe('EventLogStart', () => { + const savedObjectGetter = jest.fn(); + let esContext: jest.Mocked & { + esAdapter: jest.Mocked; + }; + let eventLogClient: EventLogClient; + beforeEach(() => { + esContext = contextMock.create(); + eventLogClient = new EventLogClient({ + esContext, + savedObjectGetter, + request: FakeRequest(), + }); + }); + afterEach(() => { + jest.resetAllMocks(); + }); + describe('findEventsBySavedObjectIds', () => { test('verifies that the user can access the specified saved object', async () => { - const esContext = contextMock.create(); - const savedObjectGetter = jest.fn(); - - const eventLogClient = new EventLogClient({ - esContext, - savedObjectGetter, - request: FakeRequest(), - }); - - savedObjectGetter.mockResolvedValueOnce({ - id: 'saved-object-id', - type: 'saved-object-type', - attributes: {}, - references: [], - }); - + savedObjectGetter.mockResolvedValueOnce(expectedSavedObject); await eventLogClient.findEventsBySavedObjectIds('saved-object-type', ['saved-object-id']); - expect(savedObjectGetter).toHaveBeenCalledWith('saved-object-type', ['saved-object-id']); }); - test('throws when the user doesnt have permission to access the specified saved object', async () => { - const esContext = contextMock.create(); - - const savedObjectGetter = jest.fn(); - - const eventLogClient = new EventLogClient({ - esContext, - savedObjectGetter, - request: FakeRequest(), - }); - savedObjectGetter.mockRejectedValue(new Error('Fail')); - - expect( + await expect( eventLogClient.findEventsBySavedObjectIds('saved-object-type', ['saved-object-id']) ).rejects.toMatchInlineSnapshot(`[Error: Fail]`); }); - test('fetches all event that reference the saved object', async () => { - const esContext = contextMock.create(); - - const savedObjectGetter = jest.fn(); - - const eventLogClient = new EventLogClient({ - esContext, - savedObjectGetter, - request: FakeRequest(), - }); - - savedObjectGetter.mockResolvedValueOnce({ - id: 'saved-object-id', - type: 'saved-object-type', - attributes: {}, - references: [], - }); - - const expectedEvents = [ - fakeEvent({ - kibana: { - saved_objects: [ - { - id: 'saved-object-id', - type: 'saved-object-type', - }, - { - type: 'action', - id: '1', - }, - ], - }, - }), - fakeEvent({ - kibana: { - saved_objects: [ - { - id: 'saved-object-id', - type: 'saved-object-type', - }, - { - type: 'action', - id: '2', - }, - ], - }, - }), - ]; - + savedObjectGetter.mockResolvedValueOnce(expectedSavedObject); const result = { page: 0, per_page: 10, @@ -109,7 +90,6 @@ describe('EventLogStart', () => { data: expectedEvents, }; esContext.esAdapter.queryEventsBySavedObjects.mockResolvedValue(result); - expect( await eventLogClient.findEventsBySavedObjectIds( 'saved-object-type', @@ -118,7 +98,6 @@ describe('EventLogStart', () => { ['legacy-id'] ) ).toEqual(result); - expect(esContext.esAdapter.queryEventsBySavedObjects).toHaveBeenCalledWith({ index: esContext.esNames.indexPattern, namespace: undefined, @@ -127,62 +106,18 @@ describe('EventLogStart', () => { findOptions: { page: 1, per_page: 10, - sort_field: '@timestamp', - sort_order: 'asc', + sort: [ + { + sort_field: '@timestamp', + sort_order: 'asc', + }, + ], }, legacyIds: ['legacy-id'], }); }); - test('fetches all events in time frame that reference the saved object', async () => { - const esContext = contextMock.create(); - - const savedObjectGetter = jest.fn(); - - const eventLogClient = new EventLogClient({ - esContext, - savedObjectGetter, - request: FakeRequest(), - }); - - savedObjectGetter.mockResolvedValueOnce({ - id: 'saved-object-id', - type: 'saved-object-type', - attributes: {}, - references: [], - }); - - const expectedEvents = [ - fakeEvent({ - kibana: { - saved_objects: [ - { - id: 'saved-object-id', - type: 'saved-object-type', - }, - { - type: 'action', - id: '1', - }, - ], - }, - }), - fakeEvent({ - kibana: { - saved_objects: [ - { - id: 'saved-object-id', - type: 'saved-object-type', - }, - { - type: 'action', - id: '2', - }, - ], - }, - }), - ]; - + savedObjectGetter.mockResolvedValueOnce(expectedSavedObject); const result = { page: 0, per_page: 10, @@ -190,10 +125,8 @@ describe('EventLogStart', () => { data: expectedEvents, }; esContext.esAdapter.queryEventsBySavedObjects.mockResolvedValue(result); - const start = moment().subtract(1, 'days').toISOString(); const end = moment().add(1, 'days').toISOString(); - expect( await eventLogClient.findEventsBySavedObjectIds( 'saved-object-type', @@ -205,7 +138,6 @@ describe('EventLogStart', () => { ['legacy-id'] ) ).toEqual(result); - expect(esContext.esAdapter.queryEventsBySavedObjects).toHaveBeenCalledWith({ index: esContext.esNames.indexPattern, namespace: undefined, @@ -214,72 +146,40 @@ describe('EventLogStart', () => { findOptions: { page: 1, per_page: 10, - sort_field: '@timestamp', - sort_order: 'asc', + sort: [ + { + sort_field: '@timestamp', + sort_order: 'asc', + }, + ], start, end, }, legacyIds: ['legacy-id'], }); }); - test('validates that the start date is valid', async () => { - const esContext = contextMock.create(); - - const savedObjectGetter = jest.fn(); - - const eventLogClient = new EventLogClient({ - esContext, - savedObjectGetter, - request: FakeRequest(), - }); - - savedObjectGetter.mockResolvedValueOnce({ - id: 'saved-object-id', - type: 'saved-object-type', - attributes: {}, - references: [], - }); - + savedObjectGetter.mockResolvedValueOnce(expectedSavedObject); esContext.esAdapter.queryEventsBySavedObjects.mockResolvedValue({ page: 0, per_page: 0, total: 0, data: [], }); - expect( eventLogClient.findEventsBySavedObjectIds('saved-object-type', ['saved-object-id'], { start: 'not a date string', }) ).rejects.toMatchInlineSnapshot(`[Error: [start]: Invalid Date]`); }); - test('validates that the end date is valid', async () => { - const esContext = contextMock.create(); - - const savedObjectGetter = jest.fn(); - - const eventLogClient = new EventLogClient({ - esContext, - savedObjectGetter, - request: FakeRequest(), - }); - - savedObjectGetter.mockResolvedValueOnce({ - id: 'saved-object-id', - type: 'saved-object-type', - attributes: {}, - references: [], - }); - + savedObjectGetter.mockResolvedValueOnce(expectedSavedObject); esContext.esAdapter.queryEventsBySavedObjects.mockResolvedValue({ page: 0, per_page: 0, total: 0, data: [], }); - expect( eventLogClient.findEventsBySavedObjectIds('saved-object-type', ['saved-object-id'], { end: 'not a date string', @@ -287,6 +187,59 @@ describe('EventLogStart', () => { ).rejects.toMatchInlineSnapshot(`[Error: [end]: Invalid Date]`); }); }); + + describe('aggregateEventsBySavedObjectIds', () => { + test('verifies that the user can access the specified saved object', async () => { + savedObjectGetter.mockResolvedValueOnce(expectedSavedObject); + await eventLogClient.aggregateEventsBySavedObjectIds( + 'saved-object-type', + ['saved-object-id'], + { aggs: {} } + ); + expect(savedObjectGetter).toHaveBeenCalledWith('saved-object-type', ['saved-object-id']); + }); + test('throws when no aggregation is defined in options', async () => { + savedObjectGetter.mockResolvedValueOnce(expectedSavedObject); + await expect( + eventLogClient.aggregateEventsBySavedObjectIds('saved-object-type', ['saved-object-id']) + ).rejects.toMatchInlineSnapshot(`[Error: No aggregation defined!]`); + }); + test('throws when the user doesnt have permission to access the specified saved object', async () => { + savedObjectGetter.mockRejectedValue(new Error('Fail')); + await expect( + eventLogClient.aggregateEventsBySavedObjectIds('saved-object-type', ['saved-object-id'], { + aggs: {}, + }) + ).rejects.toMatchInlineSnapshot(`[Error: Fail]`); + }); + test('calls aggregateEventsBySavedObjects with given aggregation', async () => { + savedObjectGetter.mockResolvedValueOnce(expectedSavedObject); + await eventLogClient.aggregateEventsBySavedObjectIds( + 'saved-object-type', + ['saved-object-id'], + { aggs: { myAgg: {} } } + ); + expect(savedObjectGetter).toHaveBeenCalledWith('saved-object-type', ['saved-object-id']); + expect(esContext.esAdapter.aggregateEventsBySavedObjects).toHaveBeenCalledWith({ + index: esContext.esNames.indexPattern, + namespace: undefined, + type: 'saved-object-type', + ids: ['saved-object-id'], + aggregateOptions: { + aggs: { myAgg: {} }, + page: 1, + per_page: 10, + sort: [ + { + sort_field: '@timestamp', + sort_order: 'asc', + }, + ], + }, + legacyIds: undefined, + }); + }); + }); }); function fakeEvent(overrides = {}) { diff --git a/x-pack/plugins/event_log/server/event_log_client.ts b/x-pack/plugins/event_log/server/event_log_client.ts index 39b78296e3875..c832ab9056be1 100644 --- a/x-pack/plugins/event_log/server/event_log_client.ts +++ b/x-pack/plugins/event_log/server/event_log_client.ts @@ -5,9 +5,11 @@ * 2.0. */ +import { omit } from 'lodash'; import { Observable } from 'rxjs'; import { schema, TypeOf } from '@kbn/config-schema'; import { IClusterClient, KibanaRequest } from 'src/core/server'; +import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { SpacesServiceStart } from '../../spaces/server'; import { EsContext } from './es'; @@ -27,37 +29,44 @@ const optionalDateFieldSchema = schema.maybe( }) ); -export const findOptionsSchema = schema.object({ +const sortSchema = schema.object({ + sort_field: schema.oneOf([ + schema.literal('@timestamp'), + schema.literal('event.start'), + schema.literal('event.end'), + schema.literal('event.provider'), + schema.literal('event.duration'), + schema.literal('event.action'), + schema.literal('message'), + ]), + sort_order: schema.oneOf([schema.literal('asc'), schema.literal('desc')]), +}); + +export const queryOptionsSchema = schema.object({ per_page: schema.number({ defaultValue: 10, min: 0 }), page: schema.number({ defaultValue: 1, min: 1 }), start: optionalDateFieldSchema, end: optionalDateFieldSchema, - sort_field: schema.oneOf( - [ - schema.literal('@timestamp'), - schema.literal('event.start'), - schema.literal('event.end'), - schema.literal('event.provider'), - schema.literal('event.duration'), - schema.literal('event.action'), - schema.literal('message'), - ], - { - defaultValue: '@timestamp', - } - ), - sort_order: schema.oneOf([schema.literal('asc'), schema.literal('desc')], { - defaultValue: 'asc', + sort: schema.arrayOf(sortSchema, { + defaultValue: [{ sort_field: '@timestamp', sort_order: 'asc' }], }), filter: schema.maybe(schema.string()), }); + +export type QueryOptionsType = Pick, 'start' | 'end' | 'filter'>; + // page & perPage are required, other fields are optional // using schema.maybe allows us to set undefined, but not to make the field optional export type FindOptionsType = Pick< - TypeOf, - 'page' | 'per_page' | 'sort_field' | 'sort_order' | 'filter' + TypeOf, + 'page' | 'per_page' | 'sort' | 'filter' > & - Partial>; + Partial>; + +export type AggregateOptionsType = Pick, 'filter'> & + Partial> & { + aggs: Record; + }; interface EventLogServiceCtorParams { esContext: EsContext; @@ -80,27 +89,56 @@ export class EventLogClient implements IEventLogClient { this.request = request; } - async findEventsBySavedObjectIds( + public async findEventsBySavedObjectIds( type: string, ids: string[], options?: Partial, legacyIds?: string[] ): Promise { - const findOptions = findOptionsSchema.validate(options ?? {}); + const findOptions = queryOptionsSchema.validate(options ?? {}); - const space = await this.spacesService?.getActiveSpace(this.request); - const namespace = space && this.spacesService?.spaceIdToNamespace(space.id); - - // verify the user has the required permissions to view this saved objects + // verify the user has the required permissions to view this saved object await this.savedObjectGetter(type, ids); return await this.esContext.esAdapter.queryEventsBySavedObjects({ index: this.esContext.esNames.indexPattern, - namespace, + namespace: await this.getNamespace(), type, ids, findOptions, legacyIds, }); } + + public async aggregateEventsBySavedObjectIds( + type: string, + ids: string[], + options?: AggregateOptionsType, + legacyIds?: string[] + ) { + const aggs = options?.aggs; + if (!aggs) { + throw new Error('No aggregation defined!'); + } + + // validate other query options separately from + const aggregateOptions = queryOptionsSchema.validate(omit(options, 'aggs') ?? {}); + + // verify the user has the required permissions to view this saved object + await this.savedObjectGetter(type, ids); + + return await this.esContext.esAdapter.aggregateEventsBySavedObjects({ + index: this.esContext.esNames.indexPattern, + namespace: await this.getNamespace(), + type, + ids, + aggregateOptions: { ...aggregateOptions, aggs } as AggregateOptionsType, + legacyIds, + }); + } + + private async getNamespace() { + const space = await this.spacesService?.getActiveSpace(this.request); + return space && this.spacesService?.spaceIdToNamespace(space.id); + } } diff --git a/x-pack/plugins/event_log/server/index.ts b/x-pack/plugins/event_log/server/index.ts index 877c39a02edc5..42fc2e9792014 100644 --- a/x-pack/plugins/event_log/server/index.ts +++ b/x-pack/plugins/event_log/server/index.ts @@ -17,6 +17,7 @@ export type { IValidatedEvent, IEventLogClient, QueryEventsBySavedObjectResult, + AggregateEventsBySavedObjectResult, } from './types'; export { SAVED_OBJECT_REL_PRIMARY } from './types'; diff --git a/x-pack/plugins/event_log/server/routes/find.test.ts b/x-pack/plugins/event_log/server/routes/find.test.ts index b823d21a6c1f7..c51c8f3adf1e5 100644 --- a/x-pack/plugins/event_log/server/routes/find.test.ts +++ b/x-pack/plugins/event_log/server/routes/find.test.ts @@ -26,7 +26,7 @@ describe('find', () => { const [config, handler] = router.get.mock.calls[0]; - expect(config.path).toMatchInlineSnapshot(`"/api/event_log/{type}/{id}/_find"`); + expect(config.path).toMatchInlineSnapshot(`"/internal/event_log/{type}/{id}/_find"`); const events = [fakeEvent(), fakeEvent()]; const result = { diff --git a/x-pack/plugins/event_log/server/routes/find.ts b/x-pack/plugins/event_log/server/routes/find.ts index cbbd2eedd2dbc..fdb699b70e26c 100644 --- a/x-pack/plugins/event_log/server/routes/find.ts +++ b/x-pack/plugins/event_log/server/routes/find.ts @@ -14,7 +14,7 @@ import type { } from 'src/core/server'; import type { EventLogRouter, EventLogRequestHandlerContext } from '../types'; import { BASE_EVENT_LOG_API_PATH } from '../../common'; -import { findOptionsSchema, FindOptionsType } from '../event_log_client'; +import { queryOptionsSchema, FindOptionsType } from '../event_log_client'; const paramSchema = schema.object({ type: schema.string(), @@ -27,7 +27,7 @@ export const findRoute = (router: EventLogRouter, systemLogger: Logger) => { path: `${BASE_EVENT_LOG_API_PATH}/{type}/{id}/_find`, validate: { params: paramSchema, - query: findOptionsSchema, + query: queryOptionsSchema, }, }, router.handleLegacyErrors(async function ( diff --git a/x-pack/plugins/event_log/server/routes/find_by_ids.test.ts b/x-pack/plugins/event_log/server/routes/find_by_ids.test.ts index 4685306e869da..065174abcd9fd 100644 --- a/x-pack/plugins/event_log/server/routes/find_by_ids.test.ts +++ b/x-pack/plugins/event_log/server/routes/find_by_ids.test.ts @@ -26,7 +26,7 @@ describe('find_by_ids', () => { const [config, handler] = router.post.mock.calls[0]; - expect(config.path).toMatchInlineSnapshot(`"/api/event_log/{type}/_find"`); + expect(config.path).toMatchInlineSnapshot(`"/internal/event_log/{type}/_find"`); const events = [fakeEvent(), fakeEvent()]; const result = { diff --git a/x-pack/plugins/event_log/server/routes/find_by_ids.ts b/x-pack/plugins/event_log/server/routes/find_by_ids.ts index 378b9516631ad..324dbc7f568ba 100644 --- a/x-pack/plugins/event_log/server/routes/find_by_ids.ts +++ b/x-pack/plugins/event_log/server/routes/find_by_ids.ts @@ -15,7 +15,7 @@ import type { import type { EventLogRouter, EventLogRequestHandlerContext } from '../types'; import { BASE_EVENT_LOG_API_PATH } from '../../common'; -import { findOptionsSchema, FindOptionsType } from '../event_log_client'; +import { queryOptionsSchema, FindOptionsType } from '../event_log_client'; const paramSchema = schema.object({ type: schema.string(), @@ -32,7 +32,7 @@ export const findByIdsRoute = (router: EventLogRouter, systemLogger: Logger) => path: `${BASE_EVENT_LOG_API_PATH}/{type}/_find`, validate: { params: paramSchema, - query: findOptionsSchema, + query: queryOptionsSchema, body: bodySchema, }, }, diff --git a/x-pack/plugins/event_log/server/types.ts b/x-pack/plugins/event_log/server/types.ts index 54305803b090a..34aa67f313ee3 100644 --- a/x-pack/plugins/event_log/server/types.ts +++ b/x-pack/plugins/event_log/server/types.ts @@ -11,9 +11,15 @@ import type { IRouter, KibanaRequest, RequestHandlerContext } from 'src/core/ser export type { IEvent, IValidatedEvent } from '../generated/schemas'; export { EventSchema, ECS_VERSION } from '../generated/schemas'; import { IEvent } from '../generated/schemas'; -import { FindOptionsType } from './event_log_client'; -import { QueryEventsBySavedObjectResult } from './es/cluster_client_adapter'; -export type { QueryEventsBySavedObjectResult } from './es/cluster_client_adapter'; +import { AggregateOptionsType, FindOptionsType } from './event_log_client'; +import { + AggregateEventsBySavedObjectResult, + QueryEventsBySavedObjectResult, +} from './es/cluster_client_adapter'; +export type { + QueryEventsBySavedObjectResult, + AggregateEventsBySavedObjectResult, +} from './es/cluster_client_adapter'; import { SavedObjectProvider } from './saved_object_provider_registry'; export const SAVED_OBJECT_REL_PRIMARY = 'primary'; @@ -49,6 +55,12 @@ export interface IEventLogClient { options?: Partial, legacyIds?: string[] ): Promise; + aggregateEventsBySavedObjectIds( + type: string, + ids: string[], + options?: Partial, + legacyIds?: string[] + ): Promise; } export interface IEventLogger { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/event_log/event_log_reader.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/event_log/event_log_reader.ts index a3c2421edc85a..3a59c26c769ee 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/event_log/event_log_reader.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/event_log/event_log_reader.ts @@ -55,8 +55,7 @@ export const createEventLogReader = (eventLog: IEventLogClient): IEventLogReader return eventLog.findEventsBySavedObjectIds(soType, soIds, { page: 1, per_page: count, - sort_field: '@timestamp', - sort_order: 'desc', + sort: [{ sort_field: '@timestamp', sort_order: 'desc' }], filter: kqlFilter, }); }); diff --git a/x-pack/test/alerting_api_integration/common/lib/get_event_log.ts b/x-pack/test/alerting_api_integration/common/lib/get_event_log.ts index 3ee7929170338..194be6d184692 100644 --- a/x-pack/test/alerting_api_integration/common/lib/get_event_log.ts +++ b/x-pack/test/alerting_api_integration/common/lib/get_event_log.ts @@ -39,7 +39,7 @@ export async function getEventLog(params: GetEventLogParams): Promise { const { body: { data: foundEvents }, - } = await findEvents(namespace, id, { sort_field: 'event.end', sort_order: 'desc' }); + } = await findEvents(namespace, id, { + sort: [{ sort_field: 'event.end', sort_order: 'desc' }], + }); expect(foundEvents.length).to.be(expectedEvents.length); assertEventsFromApiMatchCreatedEvents(foundEvents, expectedEvents.reverse()); @@ -237,11 +239,13 @@ export default function ({ getService }: FtrProviderContext) { query: Record = {} ) { const urlPrefix = urlPrefixFromNamespace(namespace); - const url = `${urlPrefix}/api/event_log/event_log_test/${id}/_find${ + const url = `${urlPrefix}/internal/event_log/event_log_test/${id}/_find${ isEmpty(query) ? '' : `?${Object.entries(query) - .map(([key, val]) => `${key}=${val}`) + .map(([key, val]) => + typeof val === 'object' ? `${key}=${JSON.stringify(val)}` : `${key}=${val}` + ) .join('&')}` }`; await delay(1000); // wait for buffer to be written @@ -256,7 +260,7 @@ export default function ({ getService }: FtrProviderContext) { legacyIds: string[] = [] ) { const urlPrefix = urlPrefixFromNamespace(namespace); - const url = `${urlPrefix}/api/event_log/event_log_test/_find${ + const url = `${urlPrefix}/internal/event_log/event_log_test/_find${ isEmpty(query) ? '' : `?${Object.entries(query) diff --git a/x-pack/test/plugin_api_integration/test_suites/event_log/service_api_integration.ts b/x-pack/test/plugin_api_integration/test_suites/event_log/service_api_integration.ts index 267df365427a0..f317ad2dcff13 100644 --- a/x-pack/test/plugin_api_integration/test_suites/event_log/service_api_integration.ts +++ b/x-pack/test/plugin_api_integration/test_suites/event_log/service_api_integration.ts @@ -259,7 +259,7 @@ export default function ({ getService }: FtrProviderContext) { async function fetchEvents(savedObjectType: string, savedObjectId: string) { log.debug(`Fetching events of Saved Object ${savedObjectId}`); return await supertest - .get(`/api/event_log/${savedObjectType}/${savedObjectId}/_find`) + .get(`/internal/event_log/${savedObjectType}/${savedObjectId}/_find`) .set('kbn-xsrf', 'foo') .expect(200); }