diff --git a/x-pack/platform/plugins/shared/rule_registry/server/alert_data_client/alerts_client.test.ts b/x-pack/platform/plugins/shared/rule_registry/server/alert_data_client/alerts_client.test.ts index ad88f86bdcad8..7a6dc44c1b8ae 100644 --- a/x-pack/platform/plugins/shared/rule_registry/server/alert_data_client/alerts_client.test.ts +++ b/x-pack/platform/plugins/shared/rule_registry/server/alert_data_client/alerts_client.test.ts @@ -12,6 +12,12 @@ import type { ConstructorOptions } from './alerts_client'; import { AlertsClient } from './alerts_client'; import { fromKueryExpression } from '@kbn/es-query'; import { IndexPatternsFetcher } from '@kbn/data-views-plugin/server'; +import { ALERT_RULE_CONSUMER, ALERT_RULE_TYPE_ID, SPACE_IDS } from '@kbn/rule-data-utils'; +import { + STATUS_UPDATE_SCRIPT, + ADD_TAGS_UPDATE_SCRIPT, + REMOVE_TAGS_UPDATE_SCRIPT, +} from '../utils/alert_client_bulk_update_scripts'; describe('AlertsClient', () => { const alertingAuthMock = alertingAuthorizationMock.create(); @@ -501,4 +507,618 @@ describe('AlertsClient', () => { expect(response.fields).toHaveLength(0); }); }); + + describe('bulkUpdate', () => { + beforeEach(() => { + esClientMock.mget.mockResolvedValue({ + docs: [ + { + _index: '.alerts-security.alerts-default', + _id: 'alert-1', + _source: { + [ALERT_RULE_TYPE_ID]: 'test-rule-type-1', + [ALERT_RULE_CONSUMER]: 'foo', + [SPACE_IDS]: ['space-1'], + '@timestamp': '2023-01-01T00:00:00.000Z', + }, + found: true, + }, + { + _index: '.alerts-security.alerts-default', + _id: 'alert-2', + _source: { + [ALERT_RULE_TYPE_ID]: 'test-rule-type-1', + [ALERT_RULE_CONSUMER]: 'foo', + [SPACE_IDS]: ['space-1'], + '@timestamp': '2023-01-01T00:00:00.000Z', + }, + found: true, + }, + ], + }); + + esClientMock.bulk.mockResolvedValue({ + took: 5, + errors: false, + items: [ + { + update: { + _index: '.alerts-security.alerts-default', + _id: 'alert-1', + _version: 1, + result: 'updated', + _shards: { total: 1, successful: 1, failed: 0 }, + status: 200, + }, + }, + { + update: { + _index: '.alerts-security.alerts-default', + _id: 'alert-2', + _version: 1, + result: 'updated', + _shards: { total: 1, successful: 1, failed: 0 }, + status: 200, + }, + }, + ], + }); + }); + + it('should bulk update alerts with addTags only', async () => { + const result = await alertsClient.bulkUpdate({ + ids: ['alert-1', 'alert-2'], + index: '.alerts-security.alerts-default', + addTags: ['urgent', 'production'], + }); + + expect(esClientMock.mget).toHaveBeenCalledWith({ + docs: [ + { _id: 'alert-1', _index: '.alerts-security.alerts-default' }, + { _id: 'alert-2', _index: '.alerts-security.alerts-default' }, + ], + }); + + expect(esClientMock.bulk).toHaveBeenCalledWith({ + refresh: 'wait_for', + body: [ + { + update: { + _index: '.alerts-security.alerts-default', + _id: 'alert-1', + }, + }, + { + script: { + source: ADD_TAGS_UPDATE_SCRIPT, + lang: 'painless', + params: { + addTags: ['urgent', 'production'], + }, + }, + }, + { + update: { + _index: '.alerts-security.alerts-default', + _id: 'alert-2', + }, + }, + { + script: { + source: ADD_TAGS_UPDATE_SCRIPT, + lang: 'painless', + params: { + addTags: ['urgent', 'production'], + }, + }, + }, + ], + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "errors": false, + "items": Array [ + Object { + "update": Object { + "_id": "alert-1", + "_index": ".alerts-security.alerts-default", + "_shards": Object { + "failed": 0, + "successful": 1, + "total": 1, + }, + "_version": 1, + "result": "updated", + "status": 200, + }, + }, + Object { + "update": Object { + "_id": "alert-2", + "_index": ".alerts-security.alerts-default", + "_shards": Object { + "failed": 0, + "successful": 1, + "total": 1, + }, + "_version": 1, + "result": "updated", + "status": 200, + }, + }, + ], + "took": 5, + } + `); + }); + + it('should bulk update alerts with removeTags only', async () => { + const result = await alertsClient.bulkUpdate({ + ids: ['alert-1', 'alert-2'], + index: '.alerts-security.alerts-default', + removeTags: ['outdated', 'test'], + }); + + expect(esClientMock.mget).toHaveBeenCalledWith({ + docs: [ + { _id: 'alert-1', _index: '.alerts-security.alerts-default' }, + { _id: 'alert-2', _index: '.alerts-security.alerts-default' }, + ], + }); + + expect(esClientMock.bulk).toHaveBeenCalledWith({ + refresh: 'wait_for', + body: [ + { + update: { + _index: '.alerts-security.alerts-default', + _id: 'alert-1', + }, + }, + { + script: { + source: REMOVE_TAGS_UPDATE_SCRIPT, + lang: 'painless', + params: { + removeTags: ['outdated', 'test'], + }, + }, + }, + { + update: { + _index: '.alerts-security.alerts-default', + _id: 'alert-2', + }, + }, + { + script: { + source: REMOVE_TAGS_UPDATE_SCRIPT, + lang: 'painless', + params: { + removeTags: ['outdated', 'test'], + }, + }, + }, + ], + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "errors": false, + "items": Array [ + Object { + "update": Object { + "_id": "alert-1", + "_index": ".alerts-security.alerts-default", + "_shards": Object { + "failed": 0, + "successful": 1, + "total": 1, + }, + "_version": 1, + "result": "updated", + "status": 200, + }, + }, + Object { + "update": Object { + "_id": "alert-2", + "_index": ".alerts-security.alerts-default", + "_shards": Object { + "failed": 0, + "successful": 1, + "total": 1, + }, + "_version": 1, + "result": "updated", + "status": 200, + }, + }, + ], + "took": 5, + } + `); + }); + + it('should bulk update alerts with both addTags and removeTags', async () => { + const result = await alertsClient.bulkUpdate({ + ids: ['alert-1', 'alert-2'], + index: '.alerts-security.alerts-default', + addTags: ['urgent'], + removeTags: ['outdated'], + }); + + expect(esClientMock.mget).toHaveBeenCalledWith({ + docs: [ + { _id: 'alert-1', _index: '.alerts-security.alerts-default' }, + { _id: 'alert-2', _index: '.alerts-security.alerts-default' }, + ], + }); + + expect(esClientMock.bulk).toHaveBeenCalledWith({ + refresh: 'wait_for', + body: [ + { + update: { + _index: '.alerts-security.alerts-default', + _id: 'alert-1', + }, + }, + { + script: { + source: [ADD_TAGS_UPDATE_SCRIPT, REMOVE_TAGS_UPDATE_SCRIPT].join('\n'), + lang: 'painless', + params: { + addTags: ['urgent'], + removeTags: ['outdated'], + }, + }, + }, + { + update: { + _index: '.alerts-security.alerts-default', + _id: 'alert-2', + }, + }, + { + script: { + source: [ADD_TAGS_UPDATE_SCRIPT, REMOVE_TAGS_UPDATE_SCRIPT].join('\n'), + lang: 'painless', + params: { + addTags: ['urgent'], + removeTags: ['outdated'], + }, + }, + }, + ], + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "errors": false, + "items": Array [ + Object { + "update": Object { + "_id": "alert-1", + "_index": ".alerts-security.alerts-default", + "_shards": Object { + "failed": 0, + "successful": 1, + "total": 1, + }, + "_version": 1, + "result": "updated", + "status": 200, + }, + }, + Object { + "update": Object { + "_id": "alert-2", + "_index": ".alerts-security.alerts-default", + "_shards": Object { + "failed": 0, + "successful": 1, + "total": 1, + }, + "_version": 1, + "result": "updated", + "status": 200, + }, + }, + ], + "took": 5, + } + `); + }); + + it('should bulk update alerts with status and tags', async () => { + const result = await alertsClient.bulkUpdate({ + ids: ['alert-1', 'alert-2'], + index: '.alerts-security.alerts-default', + status: 'acknowledged', + addTags: ['reviewed'], + removeTags: ['pending'], + }); + + expect(esClientMock.mget).toHaveBeenCalledWith({ + docs: [ + { _id: 'alert-1', _index: '.alerts-security.alerts-default' }, + { _id: 'alert-2', _index: '.alerts-security.alerts-default' }, + ], + }); + + expect(esClientMock.bulk).toHaveBeenCalledWith({ + refresh: 'wait_for', + body: [ + { + update: { + _index: '.alerts-security.alerts-default', + _id: 'alert-1', + }, + }, + { + script: { + source: [ + STATUS_UPDATE_SCRIPT, + ADD_TAGS_UPDATE_SCRIPT, + REMOVE_TAGS_UPDATE_SCRIPT, + ].join('\n'), + lang: 'painless', + params: { + status: 'acknowledged', + addTags: ['reviewed'], + removeTags: ['pending'], + }, + }, + }, + { + update: { + _index: '.alerts-security.alerts-default', + _id: 'alert-2', + }, + }, + { + script: { + source: [ + STATUS_UPDATE_SCRIPT, + ADD_TAGS_UPDATE_SCRIPT, + REMOVE_TAGS_UPDATE_SCRIPT, + ].join('\n'), + lang: 'painless', + params: { + status: 'acknowledged', + addTags: ['reviewed'], + removeTags: ['pending'], + }, + }, + }, + ], + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "errors": false, + "items": Array [ + Object { + "update": Object { + "_id": "alert-1", + "_index": ".alerts-security.alerts-default", + "_shards": Object { + "failed": 0, + "successful": 1, + "total": 1, + }, + "_version": 1, + "result": "updated", + "status": 200, + }, + }, + Object { + "update": Object { + "_id": "alert-2", + "_index": ".alerts-security.alerts-default", + "_shards": Object { + "failed": 0, + "successful": 1, + "total": 1, + }, + "_version": 1, + "result": "updated", + "status": 200, + }, + }, + ], + "took": 5, + } + `); + }); + + it('should return early when no operations are provided', async () => { + const result = await alertsClient.bulkUpdate({ + ids: ['alert-1', 'alert-2'], + index: '.alerts-security.alerts-default', + }); + + expect(result).toMatchInlineSnapshot(`undefined`); + expect(esClientMock.mget).not.toHaveBeenCalled(); + expect(esClientMock.bulk).not.toHaveBeenCalled(); + }); + + it('should throw error when ids is empty', async () => { + await expect( + alertsClient.bulkUpdate({ + ids: [], + index: '.alerts-security.alerts-default', + addTags: ['urgent', 'production'], + }) + ).rejects.toMatchInlineSnapshot(`[Error: no ids or query were provided for updating]`); + + expect(esClientMock.mget).not.toHaveBeenCalled(); + expect(esClientMock.bulk).not.toHaveBeenCalled(); + }); + + it('should throw error when query is empty', async () => { + await expect( + alertsClient.bulkUpdate({ + query: '', + index: '.alerts-security.alerts-default', + addTags: ['urgent', 'production'], + }) + ).rejects.toMatchInlineSnapshot(`[Error: no ids or query were provided for updating]`); + + expect(esClientMock.mget).not.toHaveBeenCalled(); + expect(esClientMock.bulk).not.toHaveBeenCalled(); + }); + }); + + describe('bulkUpdate edge cases', () => { + beforeEach(() => { + esClientMock.mget.mockResolvedValue({ + docs: [ + { + _index: '.alerts-security.alerts-default', + _id: 'alert-1', + _source: { + [ALERT_RULE_TYPE_ID]: 'test-rule-type-1', + [ALERT_RULE_CONSUMER]: 'foo', + [SPACE_IDS]: ['space-1'], + '@timestamp': '2023-01-01T00:00:00.000Z', + }, + found: true, + }, + ], + }); + + esClientMock.bulk.mockResolvedValue({ + took: 5, + errors: false, + items: [ + { + update: { + _index: '.alerts-security.alerts-default', + _id: 'alert-1', + _version: 1, + result: 'updated', + _shards: { total: 1, successful: 1, failed: 0 }, + status: 200, + }, + }, + ], + }); + }); + it('should handle empty addTags array', async () => { + const result = await alertsClient.bulkUpdate({ + ids: ['alert-1'], + index: '.alerts-security.alerts-default', + addTags: [], + status: 'acknowledged', + }); + + expect(esClientMock.mget).toHaveBeenCalledWith({ + docs: [{ _id: 'alert-1', _index: '.alerts-security.alerts-default' }], + }); + + expect(esClientMock.bulk).toHaveBeenCalledWith({ + refresh: 'wait_for', + body: [ + { + update: { + _index: '.alerts-security.alerts-default', + _id: 'alert-1', + }, + }, + { + script: { + source: STATUS_UPDATE_SCRIPT, + lang: 'painless', + params: { + status: 'acknowledged', + }, + }, + }, + ], + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "errors": false, + "items": Array [ + Object { + "update": Object { + "_id": "alert-1", + "_index": ".alerts-security.alerts-default", + "_shards": Object { + "failed": 0, + "successful": 1, + "total": 1, + }, + "_version": 1, + "result": "updated", + "status": 200, + }, + }, + ], + "took": 5, + } + `); + }); + + it('should handle empty removeTags array', async () => { + const result = await alertsClient.bulkUpdate({ + ids: ['alert-1'], + index: '.alerts-security.alerts-default', + removeTags: [], + status: 'acknowledged', + }); + + expect(esClientMock.mget).toHaveBeenCalledWith({ + docs: [{ _id: 'alert-1', _index: '.alerts-security.alerts-default' }], + }); + + expect(esClientMock.bulk).toHaveBeenCalledWith({ + refresh: 'wait_for', + body: [ + { + update: { + _index: '.alerts-security.alerts-default', + _id: 'alert-1', + }, + }, + { + script: { + source: STATUS_UPDATE_SCRIPT, + lang: 'painless', + params: { + status: 'acknowledged', + }, + }, + }, + ], + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "errors": false, + "items": Array [ + Object { + "update": Object { + "_id": "alert-1", + "_index": ".alerts-security.alerts-default", + "_shards": Object { + "failed": 0, + "successful": 1, + "total": 1, + }, + "_version": 1, + "result": "updated", + "status": 200, + }, + }, + ], + "took": 5, + } + `); + }); + }); }); diff --git a/x-pack/platform/plugins/shared/rule_registry/server/alert_data_client/alerts_client.ts b/x-pack/platform/plugins/shared/rule_registry/server/alert_data_client/alerts_client.ts index 1f4a83f1663d3..ccbb3cbada080 100644 --- a/x-pack/platform/plugins/shared/rule_registry/server/alert_data_client/alerts_client.ts +++ b/x-pack/platform/plugins/shared/rule_registry/server/alert_data_client/alerts_client.ts @@ -70,6 +70,11 @@ import { getRuleTypeIdsFilter } from '../lib/get_rule_type_ids_filter'; import { getConsumersFilter } from '../lib/get_consumers_filter'; import { mergeUniqueFieldsByName } from '../utils/unique_fields'; import { getAlertFieldsFromIndexFetcher } from '../utils/get_alert_fields_from_index_fetcher'; +import { + STATUS_UPDATE_SCRIPT, + ADD_TAGS_UPDATE_SCRIPT, + REMOVE_TAGS_UPDATE_SCRIPT, +} from '../utils/alert_client_bulk_update_scripts'; import type { GetAlertFieldsResponseV1 } from '../routes/get_alert_fields'; // TODO: Fix typings https://github.com/elastic/kibana/issues/101776 @@ -113,9 +118,11 @@ export interface UpdateOptions { export interface BulkUpdateOptions { ids?: string[] | null; - status: STATUS_VALUES; + status?: STATUS_VALUES; index: string; query?: object | string | null; + addTags?: string[]; + removeTags?: string[]; } interface MgetAndAuditAlert { @@ -442,25 +449,6 @@ export class AlertsClient { } } - /** - * When an update by ids is requested, do a multi-get, ensure authz and audit alerts, then execute bulk update - */ - private async mgetAlertsAuditOperateStatus({ - alerts, - status, - operation, - }: { - alerts: MgetAndAuditAlert[]; - status: STATUS_VALUES; - operation: ReadOperations.Find | ReadOperations.Get | WriteOperations.Update; - }) { - return this.mgetAlertsAuditOperate({ - alerts, - operation, - fieldToUpdate: (source) => this.getAlertStatusFieldUpdate(source, status), - }); - } - private async buildEsQueryWithAuthz( query: object | string | null | undefined, id: string | null | undefined, @@ -549,6 +537,7 @@ export class AlertsClient { index, operation, lastSortIds, + size: 1000, }); if (lastSortIds != null && result?.hits.hits.length === 0) { @@ -810,16 +799,72 @@ export class AlertsClient { query, index, status, + addTags, + removeTags, }: BulkUpdateOptions) { + const scriptOps: string[] = []; + const params: Record = {}; + + if (status != null) { + params.status = status; + scriptOps.push(STATUS_UPDATE_SCRIPT); + } + + if (addTags != null && addTags.length > 0) { + params.addTags = addTags; + scriptOps.push(ADD_TAGS_UPDATE_SCRIPT); + } + + if (removeTags != null && removeTags.length > 0) { + params.removeTags = removeTags; + scriptOps.push(REMOVE_TAGS_UPDATE_SCRIPT); + } + + if (scriptOps.length === 0) { + return; + } + + const script = { + source: scriptOps.join('\n'), + lang: 'painless', + params: Object.keys(params).length > 0 ? params : undefined, + }; + // rejects at the route level if more than 1000 id's are passed in - if (ids != null) { + if (ids && ids.length > 0) { const alerts = ids.map((id) => ({ id, index })); - return this.mgetAlertsAuditOperateStatus({ + const mgetRes = await this.ensureAllAlertsAuthorized({ alerts, - status, operation: WriteOperations.Update, }); - } else if (query != null) { + + const bulkUpdateRequest = []; + + for (const item of mgetRes.docs) { + // @ts-expect-error doesn't handle error branch in MGetResponse + if (item.found) { + bulkUpdateRequest.push( + { + update: { + _index: item._index, + _id: item._id, + }, + }, + { script } + ); + } + } + + if (bulkUpdateRequest.length === 0) { + return; + } + + const bulkUpdateResponse = await this.esClient.bulk({ + refresh: 'wait_for', + body: bulkUpdateRequest, + }); + return bulkUpdateResponse; + } else if (query) { try { // execute search after with query + authorization filter // audit results of that query @@ -835,18 +880,11 @@ export class AlertsClient { // executes updateByQuery with query + authorization filter // used in the queryAndAuditAllAlerts function + const result = await this.esClient.updateByQuery({ index, conflicts: 'proceed', - script: { - source: `if (ctx._source['${ALERT_WORKFLOW_STATUS}'] != null) { - ctx._source['${ALERT_WORKFLOW_STATUS}'] = '${status}' - } - if (ctx._source.signal != null && ctx._source.signal.status != null) { - ctx._source.signal.status = '${status}' - }`, - lang: 'painless', - }, + script, query: fetchAndAuditResponse.authorizedQuery as Omit, ignore_unavailable: true, }); diff --git a/x-pack/platform/plugins/shared/rule_registry/server/routes/bulk_update_alerts.ts b/x-pack/platform/plugins/shared/rule_registry/server/routes/bulk_update_alerts.ts index e85ce4b4a873f..290bf15493b56 100644 --- a/x-pack/platform/plugins/shared/rule_registry/server/routes/bulk_update_alerts.ts +++ b/x-pack/platform/plugins/shared/rule_registry/server/routes/bulk_update_alerts.ts @@ -20,28 +20,40 @@ export const bulkUpdateAlertsRoute = (router: IRouter) validate: { body: buildRouteValidation( t.union([ - t.strict({ - status: t.union([ - t.literal('open'), - t.literal('closed'), - t.literal('in-progress'), // TODO: remove after migration to acknowledged - t.literal('acknowledged'), - ]), - index: t.string, - ids: t.array(t.string), - query: t.undefined, - }), - t.strict({ - status: t.union([ - t.literal('open'), - t.literal('closed'), - t.literal('in-progress'), // TODO: remove after migration to acknowledged - t.literal('acknowledged'), - ]), - index: t.string, - ids: t.undefined, - query: t.union([t.object, t.string]), - }), + t.intersection([ + t.strict({ + index: t.string, + ids: t.array(t.string), + query: t.undefined, + }), + t.partial({ + status: t.union([ + t.literal('open'), + t.literal('closed'), + t.literal('in-progress'), // TODO: remove after migration to acknowledged + t.literal('acknowledged'), + ]), + addTags: t.array(t.string), + removeTags: t.array(t.string), + }), + ]), + t.intersection([ + t.strict({ + index: t.string, + ids: t.undefined, + query: t.union([t.object, t.string]), + }), + t.partial({ + status: t.union([ + t.literal('open'), + t.literal('closed'), + t.literal('in-progress'), // TODO: remove after migration to acknowledged + t.literal('acknowledged'), + ]), + addTags: t.array(t.string), + removeTags: t.array(t.string), + }), + ]), ]) ), }, @@ -58,7 +70,7 @@ export const bulkUpdateAlertsRoute = (router: IRouter) try { const racContext = await context.rac; const alertsClient = await racContext.getAlertsClient(); - const { status, ids, index, query } = req.body; + const { status, ids, index, query, addTags, removeTags } = req.body; if (ids != null && ids.length > 1000) { return response.badRequest({ @@ -73,6 +85,8 @@ export const bulkUpdateAlertsRoute = (router: IRouter) status, query, index, + addTags, + removeTags, }); if (updatedAlert == null) { diff --git a/x-pack/platform/plugins/shared/rule_registry/server/utils/alert_client_bulk_update_scripts.ts b/x-pack/platform/plugins/shared/rule_registry/server/utils/alert_client_bulk_update_scripts.ts new file mode 100644 index 0000000000000..4b522a0a63785 --- /dev/null +++ b/x-pack/platform/plugins/shared/rule_registry/server/utils/alert_client_bulk_update_scripts.ts @@ -0,0 +1,42 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + ALERT_WORKFLOW_STATUS, + ALERT_WORKFLOW_TAGS, +} from '../../common/technical_rule_data_field_names'; + +export const STATUS_UPDATE_SCRIPT = ` + if (ctx._source['${ALERT_WORKFLOW_STATUS}'] != null) { + ctx._source['${ALERT_WORKFLOW_STATUS}'] = params.status; + } + if (ctx._source.signal != null && ctx._source.signal.status != null) { + ctx._source.signal.status = params.status; + } +`; + +export const ADD_TAGS_UPDATE_SCRIPT = ` + if (ctx._source['${ALERT_WORKFLOW_TAGS}'] == null) { + ctx._source['${ALERT_WORKFLOW_TAGS}'] = new ArrayList(); + } + for (item in params.addTags) { + if (!ctx._source['${ALERT_WORKFLOW_TAGS}'].contains(item)) { + ctx._source['${ALERT_WORKFLOW_TAGS}'].add(item); + } + } +`; + +export const REMOVE_TAGS_UPDATE_SCRIPT = ` + if (ctx._source['${ALERT_WORKFLOW_TAGS}'] != null) { + for (int i = 0; i < params.removeTags.length; i++) { + if (ctx._source['${ALERT_WORKFLOW_TAGS}'].contains(params.removeTags[i])) { + int index = ctx._source['${ALERT_WORKFLOW_TAGS}'].indexOf(params.removeTags[i]); + ctx._source['${ALERT_WORKFLOW_TAGS}'].remove(index); + } + } + } +`;