diff --git a/x-pack/plugins/actions/server/actions_client/actions_client.mock.ts b/x-pack/plugins/actions/server/actions_client/actions_client.mock.ts index 5a369272617a2..81e2a3d023ce3 100644 --- a/x-pack/plugins/actions/server/actions_client/actions_client.mock.ts +++ b/x-pack/plugins/actions/server/actions_client/actions_client.mock.ts @@ -18,6 +18,7 @@ const createActionsClientMock = () => { delete: jest.fn(), update: jest.fn(), getAll: jest.fn(), + getAllSystemActions: jest.fn(), getBulk: jest.fn(), getOAuthAccessToken: jest.fn(), execute: jest.fn(), diff --git a/x-pack/plugins/actions/server/actions_client/actions_client.ts b/x-pack/plugins/actions/server/actions_client/actions_client.ts index f5cf395721795..0d6b207b61cda 100644 --- a/x-pack/plugins/actions/server/actions_client/actions_client.ts +++ b/x-pack/plugins/actions/server/actions_client/actions_client.ts @@ -90,6 +90,7 @@ import { } from '../lib/get_execution_log_aggregation'; import { connectorFromSavedObject, isConnectorDeprecated } from '../application/connector/lib'; import { ListTypesParams } from '../application/connector/methods/list_types/types'; +import { getAllSystemActions } from '../application/connector/methods/get_all/get_all'; interface ActionUpdate { name: string; @@ -481,6 +482,13 @@ export class ActionsClient { return getAll({ context: this.context, includeSystemActions }); } + /** + * Get all system connectors + */ + public async getAllSystemActions(): Promise { + return getAllSystemActions({ context: this.context }); + } + /** * Get bulk actions with in-memory list */ diff --git a/x-pack/plugins/actions/server/application/connector/methods/get_all/get_all.test.ts b/x-pack/plugins/actions/server/application/connector/methods/get_all/get_all.test.ts index 2e264300490f8..f472bf0117c45 100644 --- a/x-pack/plugins/actions/server/application/connector/methods/get_all/get_all.test.ts +++ b/x-pack/plugins/actions/server/application/connector/methods/get_all/get_all.test.ts @@ -108,8 +108,152 @@ describe('getAll()', () => { getEventLogClient.mockResolvedValue(eventLogClient); }); - describe('authorization', () => { - function getAllOperation(): ReturnType { + describe('getAll()', () => { + describe('authorization', () => { + function getAllOperation(): ReturnType { + const expectedResult = { + total: 1, + per_page: 10, + page: 1, + saved_objects: [ + { + id: '1', + type: 'type', + attributes: { + name: 'test', + config: { + foo: 'bar', + }, + }, + score: 1, + references: [], + }, + ], + }; + unsecuredSavedObjectsClient.find.mockResolvedValueOnce(expectedResult); + scopedClusterClient.asInternalUser.search.mockResponse( + // @ts-expect-error not full search response + { + aggregations: { + '1': { doc_count: 6 }, + testPreconfigured: { doc_count: 2 }, + }, + } + ); + + actionsClient = new ActionsClient({ + logger, + actionTypeRegistry, + unsecuredSavedObjectsClient, + scopedClusterClient, + kibanaIndices, + actionExecutor, + ephemeralExecutionEnqueuer, + bulkExecutionEnqueuer, + request, + authorization: authorization as unknown as ActionsAuthorization, + inMemoryConnectors: [ + { + id: 'testPreconfigured', + actionTypeId: '.slack', + secrets: {}, + isPreconfigured: true, + isDeprecated: false, + isSystemAction: false, + name: 'test', + config: { + foo: 'bar', + }, + }, + ], + connectorTokenClient: connectorTokenClientMock.create(), + getEventLogClient, + }); + return actionsClient.getAll(); + } + + test('ensures user is authorised to get the type of action', async () => { + await getAllOperation(); + expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ operation: 'get' }); + }); + + test('throws when user is not authorised to create the type of action', async () => { + authorization.ensureAuthorized.mockRejectedValue( + new Error(`Unauthorized to get all actions`) + ); + + await expect(getAllOperation()).rejects.toMatchInlineSnapshot( + `[Error: Unauthorized to get all actions]` + ); + + expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ operation: 'get' }); + }); + }); + + describe('auditLogger', () => { + test('logs audit event when searching connectors', async () => { + unsecuredSavedObjectsClient.find.mockResolvedValueOnce({ + total: 1, + per_page: 10, + page: 1, + saved_objects: [ + { + id: '1', + type: 'type', + attributes: { + name: 'test', + isMissingSecrets: false, + config: { + foo: 'bar', + }, + }, + score: 1, + references: [], + }, + ], + }); + + scopedClusterClient.asInternalUser.search.mockResponse( + // @ts-expect-error not full search response + { + aggregations: { + '1': { doc_count: 6 }, + testPreconfigured: { doc_count: 2 }, + }, + } + ); + + await actionsClient.getAll(); + + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'connector_find', + outcome: 'success', + }), + kibana: { saved_object: { id: '1', type: 'action' } }, + }) + ); + }); + + test('logs audit event when not authorised to search connectors', async () => { + authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized')); + + await expect(actionsClient.getAll()).rejects.toThrow(); + + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'connector_find', + outcome: 'failure', + }), + error: { code: 'Error', message: 'Unauthorized' }, + }) + ); + }); + }); + + test('calls unsecuredSavedObjectsClient with parameters and returns inMemoryConnectors correctly', async () => { const expectedResult = { total: 1, per_page: 10, @@ -120,6 +264,7 @@ describe('getAll()', () => { type: 'type', attributes: { name: 'test', + isMissingSecrets: false, config: { foo: 'bar', }, @@ -136,6 +281,7 @@ describe('getAll()', () => { aggregations: { '1': { doc_count: 6 }, testPreconfigured: { doc_count: 2 }, + 'system-connector-.cases': { doc_count: 2 }, }, } ); @@ -164,34 +310,54 @@ describe('getAll()', () => { foo: 'bar', }, }, + /** + * System actions will not + * be returned from getAll + * if no options are provided + */ + { + id: 'system-connector-.cases', + actionTypeId: '.cases', + name: 'System action: .cases', + config: {}, + secrets: {}, + isDeprecated: false, + isMissingSecrets: false, + isPreconfigured: false, + isSystemAction: true, + }, ], connectorTokenClient: connectorTokenClientMock.create(), getEventLogClient, }); - return actionsClient.getAll(); - } - - test('ensures user is authorised to get the type of action', async () => { - await getAllOperation(); - expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ operation: 'get' }); - }); - test('throws when user is not authorised to create the type of action', async () => { - authorization.ensureAuthorized.mockRejectedValue( - new Error(`Unauthorized to get all actions`) - ); + const result = await actionsClient.getAll(); - await expect(getAllOperation()).rejects.toMatchInlineSnapshot( - `[Error: Unauthorized to get all actions]` - ); - - expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ operation: 'get' }); + expect(result).toEqual([ + { + id: '1', + name: 'test', + isMissingSecrets: false, + config: { foo: 'bar' }, + isPreconfigured: false, + isDeprecated: false, + isSystemAction: false, + referencedByCount: 6, + }, + { + id: 'testPreconfigured', + actionTypeId: '.slack', + name: 'test', + isPreconfigured: true, + isSystemAction: false, + isDeprecated: false, + referencedByCount: 2, + }, + ]); }); - }); - describe('auditLogger', () => { - test('logs audit event when searching connectors', async () => { - unsecuredSavedObjectsClient.find.mockResolvedValueOnce({ + test('get system actions correctly', async () => { + const expectedResult = { total: 1, per_page: 10, page: 1, @@ -210,344 +376,328 @@ describe('getAll()', () => { references: [], }, ], - }); + }; + unsecuredSavedObjectsClient.find.mockResolvedValueOnce(expectedResult); scopedClusterClient.asInternalUser.search.mockResponse( // @ts-expect-error not full search response { aggregations: { '1': { doc_count: 6 }, testPreconfigured: { doc_count: 2 }, + 'system-connector-.cases': { doc_count: 2 }, }, } ); - await actionsClient.getAll(); - - expect(auditLogger.log).toHaveBeenCalledWith( - expect.objectContaining({ - event: expect.objectContaining({ - action: 'connector_find', - outcome: 'success', - }), - kibana: { saved_object: { id: '1', type: 'action' } }, - }) - ); - }); - - test('logs audit event when not authorised to search connectors', async () => { - authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized')); - - await expect(actionsClient.getAll()).rejects.toThrow(); - - expect(auditLogger.log).toHaveBeenCalledWith( - expect.objectContaining({ - event: expect.objectContaining({ - action: 'connector_find', - outcome: 'failure', - }), - error: { code: 'Error', message: 'Unauthorized' }, - }) - ); - }); - }); - - test('calls unsecuredSavedObjectsClient with parameters and returns inMemoryConnectors correctly', async () => { - const expectedResult = { - total: 1, - per_page: 10, - page: 1, - saved_objects: [ - { - id: '1', - type: 'type', - attributes: { + actionsClient = new ActionsClient({ + logger, + actionTypeRegistry, + unsecuredSavedObjectsClient, + scopedClusterClient, + kibanaIndices, + actionExecutor, + ephemeralExecutionEnqueuer, + bulkExecutionEnqueuer, + request, + authorization: authorization as unknown as ActionsAuthorization, + inMemoryConnectors: [ + { + id: 'testPreconfigured', + actionTypeId: '.slack', + secrets: {}, + isPreconfigured: true, + isDeprecated: false, + isSystemAction: false, name: 'test', - isMissingSecrets: false, config: { foo: 'bar', }, }, - score: 1, - references: [], + { + id: 'system-connector-.cases', + actionTypeId: '.cases', + name: 'System action: .cases', + config: {}, + secrets: {}, + isDeprecated: false, + isMissingSecrets: false, + isPreconfigured: false, + isSystemAction: true, + }, + ], + connectorTokenClient: connectorTokenClientMock.create(), + getEventLogClient, + }); + + const result = await actionsClient.getAll({ includeSystemActions: true }); + + expect(result).toEqual([ + { + actionTypeId: '.cases', + id: 'system-connector-.cases', + isDeprecated: false, + isPreconfigured: false, + isSystemAction: true, + name: 'System action: .cases', + referencedByCount: 2, }, - ], - }; - unsecuredSavedObjectsClient.find.mockResolvedValueOnce(expectedResult); - scopedClusterClient.asInternalUser.search.mockResponse( - // @ts-expect-error not full search response - { - aggregations: { - '1': { doc_count: 6 }, - testPreconfigured: { doc_count: 2 }, - 'system-connector-.cases': { doc_count: 2 }, + { + id: '1', + name: 'test', + isMissingSecrets: false, + config: { foo: 'bar' }, + isPreconfigured: false, + isDeprecated: false, + isSystemAction: false, + referencedByCount: 6, }, - } - ); - - actionsClient = new ActionsClient({ - logger, - actionTypeRegistry, - unsecuredSavedObjectsClient, - scopedClusterClient, - kibanaIndices, - actionExecutor, - ephemeralExecutionEnqueuer, - bulkExecutionEnqueuer, - request, - authorization: authorization as unknown as ActionsAuthorization, - inMemoryConnectors: [ { id: 'testPreconfigured', actionTypeId: '.slack', - secrets: {}, + name: 'test', isPreconfigured: true, - isDeprecated: false, isSystemAction: false, - name: 'test', - config: { - foo: 'bar', - }, - }, - /** - * System actions will not - * be returned from getAll - * if no options are provided - */ - { - id: 'system-connector-.cases', - actionTypeId: '.cases', - name: 'System action: .cases', - config: {}, - secrets: {}, isDeprecated: false, - isMissingSecrets: false, - isPreconfigured: false, - isSystemAction: true, + referencedByCount: 2, }, - ], - connectorTokenClient: connectorTokenClientMock.create(), - getEventLogClient, + ]); }); - const result = await actionsClient.getAll(); - - expect(result).toEqual([ - { - id: '1', - name: 'test', - isMissingSecrets: false, - config: { foo: 'bar' }, - isPreconfigured: false, - isDeprecated: false, - isSystemAction: false, - referencedByCount: 6, - }, - { - id: 'testPreconfigured', - actionTypeId: '.slack', - name: 'test', - isPreconfigured: true, - isSystemAction: false, - isDeprecated: false, - referencedByCount: 2, - }, - ]); - }); - - test('get system actions correctly', async () => { - const expectedResult = { - total: 1, - per_page: 10, - page: 1, - saved_objects: [ + test('validates connectors before return', async () => { + unsecuredSavedObjectsClient.find.mockResolvedValueOnce({ + total: 1, + per_page: 10, + page: 1, + saved_objects: [ + { + id: '1', + type: 'type', + attributes: { + name: 'test', + isMissingSecrets: false, + config: { + foo: 'bar', + }, + }, + score: 1, + references: [], + }, + ], + }); + scopedClusterClient.asInternalUser.search.mockResponse( + // @ts-expect-error not full search response { - id: '1', - type: 'type', - attributes: { + aggregations: { + '1': { doc_count: 6 }, + testPreconfigured: { doc_count: 2 }, + }, + } + ); + + actionsClient = new ActionsClient({ + logger, + actionTypeRegistry, + unsecuredSavedObjectsClient, + scopedClusterClient, + kibanaIndices, + actionExecutor, + ephemeralExecutionEnqueuer, + bulkExecutionEnqueuer, + request, + authorization: authorization as unknown as ActionsAuthorization, + inMemoryConnectors: [ + { + id: 'testPreconfigured', + actionTypeId: '.slack', + secrets: {}, + isPreconfigured: true, + isDeprecated: false, + isSystemAction: false, name: 'test', - isMissingSecrets: false, config: { foo: 'bar', }, }, - score: 1, - references: [], - }, - ], - }; - unsecuredSavedObjectsClient.find.mockResolvedValueOnce(expectedResult); - scopedClusterClient.asInternalUser.search.mockResponse( - // @ts-expect-error not full search response - { - aggregations: { - '1': { doc_count: 6 }, - testPreconfigured: { doc_count: 2 }, - 'system-connector-.cases': { doc_count: 2 }, - }, - } - ); + ], + connectorTokenClient: connectorTokenClientMock.create(), + getEventLogClient, + }); - actionsClient = new ActionsClient({ - logger, - actionTypeRegistry, - unsecuredSavedObjectsClient, - scopedClusterClient, - kibanaIndices, - actionExecutor, - ephemeralExecutionEnqueuer, - bulkExecutionEnqueuer, - request, - authorization: authorization as unknown as ActionsAuthorization, - inMemoryConnectors: [ + const result = await actionsClient.getAll({ includeSystemActions: true }); + expect(result).toEqual([ { - id: 'testPreconfigured', - actionTypeId: '.slack', - secrets: {}, - isPreconfigured: true, - isDeprecated: false, - isSystemAction: false, - name: 'test', config: { foo: 'bar', }, - }, - { - id: 'system-connector-.cases', - actionTypeId: '.cases', - name: 'System action: .cases', - config: {}, - secrets: {}, + id: '1', isDeprecated: false, isMissingSecrets: false, isPreconfigured: false, - isSystemAction: true, + isSystemAction: false, + name: 'test', + referencedByCount: 6, }, - ], - connectorTokenClient: connectorTokenClientMock.create(), - getEventLogClient, - }); + { + actionTypeId: '.slack', + id: 'testPreconfigured', + isDeprecated: false, + isPreconfigured: true, + isSystemAction: false, + name: 'test', + referencedByCount: 2, + }, + ]); - const result = await actionsClient.getAll({ includeSystemActions: true }); - - expect(result).toEqual([ - { - actionTypeId: '.cases', - id: 'system-connector-.cases', - isDeprecated: false, - isPreconfigured: false, - isSystemAction: true, - name: 'System action: .cases', - referencedByCount: 2, - }, - { - id: '1', - name: 'test', - isMissingSecrets: false, - config: { foo: 'bar' }, - isPreconfigured: false, - isDeprecated: false, - isSystemAction: false, - referencedByCount: 6, - }, - { - id: 'testPreconfigured', - actionTypeId: '.slack', - name: 'test', - isPreconfigured: true, - isSystemAction: false, - isDeprecated: false, - referencedByCount: 2, - }, - ]); + expect(logger.warn).toHaveBeenCalledWith( + 'Error validating connector: 1, Error: [actionTypeId]: expected value of type [string] but got [undefined]' + ); + }); }); - test('validates connectors before return', async () => { - unsecuredSavedObjectsClient.find.mockResolvedValueOnce({ - total: 1, - per_page: 10, - page: 1, - saved_objects: [ + describe('getAllSystemActions()', () => { + describe('authorization', () => { + function getAllOperation(): ReturnType { + scopedClusterClient.asInternalUser.search.mockResponse( + // @ts-expect-error not full search response + { + aggregations: { + 'system-connector-.test': { doc_count: 2 }, + }, + } + ); + + actionsClient = new ActionsClient({ + logger, + actionTypeRegistry, + unsecuredSavedObjectsClient, + scopedClusterClient, + kibanaIndices, + actionExecutor, + ephemeralExecutionEnqueuer, + bulkExecutionEnqueuer, + request, + authorization: authorization as unknown as ActionsAuthorization, + inMemoryConnectors: [ + { + id: 'system-connector-.test', + actionTypeId: '.test', + name: 'Test system action', + config: {}, + secrets: {}, + isDeprecated: false, + isMissingSecrets: false, + isPreconfigured: false, + isSystemAction: true, + }, + ], + connectorTokenClient: connectorTokenClientMock.create(), + getEventLogClient, + }); + + return actionsClient.getAllSystemActions(); + } + + test('ensures user is authorised to get the type of action', async () => { + await getAllOperation(); + expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ operation: 'get' }); + }); + + test('throws when user is not authorised to get the type of action', async () => { + authorization.ensureAuthorized.mockRejectedValue( + new Error(`Unauthorized to get all actions`) + ); + + await expect(getAllOperation()).rejects.toMatchInlineSnapshot( + `[Error: Unauthorized to get all actions]` + ); + + expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ operation: 'get' }); + }); + }); + + describe('auditLogger', () => { + test('logs audit event when not authorised to search connectors', async () => { + authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized')); + + await expect(actionsClient.getAllSystemActions()).rejects.toThrow(); + + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'connector_find', + outcome: 'failure', + }), + error: { code: 'Error', message: 'Unauthorized' }, + }) + ); + }); + }); + + test('get all system actions correctly', async () => { + scopedClusterClient.asInternalUser.search.mockResponse( + // @ts-expect-error not full search response { - id: '1', - type: 'type', - attributes: { + aggregations: { + 'system-connector-.test': { doc_count: 2 }, + }, + } + ); + + actionsClient = new ActionsClient({ + logger, + actionTypeRegistry, + unsecuredSavedObjectsClient, + scopedClusterClient, + kibanaIndices, + actionExecutor, + ephemeralExecutionEnqueuer, + bulkExecutionEnqueuer, + request, + authorization: authorization as unknown as ActionsAuthorization, + inMemoryConnectors: [ + { + id: 'testPreconfigured', + actionTypeId: 'my-action-type', + secrets: { + test: 'test1', + }, + isPreconfigured: true, + isDeprecated: false, + isSystemAction: false, name: 'test', - isMissingSecrets: false, config: { foo: 'bar', }, }, - score: 1, - references: [], - }, - ], - }); - scopedClusterClient.asInternalUser.search.mockResponse( - // @ts-expect-error not full search response - { - aggregations: { - '1': { doc_count: 6 }, - testPreconfigured: { doc_count: 2 }, - }, - } - ); + { + id: 'system-connector-.test', + actionTypeId: '.test', + name: 'Test system action', + config: {}, + secrets: {}, + isDeprecated: false, + isMissingSecrets: false, + isPreconfigured: false, + isSystemAction: true, + }, + ], + connectorTokenClient: connectorTokenClientMock.create(), + getEventLogClient, + }); - actionsClient = new ActionsClient({ - logger, - actionTypeRegistry, - unsecuredSavedObjectsClient, - scopedClusterClient, - kibanaIndices, - actionExecutor, - ephemeralExecutionEnqueuer, - bulkExecutionEnqueuer, - request, - authorization: authorization as unknown as ActionsAuthorization, - inMemoryConnectors: [ + const result = await actionsClient.getAllSystemActions(); + + expect(result).toEqual([ { - id: 'testPreconfigured', - actionTypeId: '.slack', - secrets: {}, - isPreconfigured: true, + id: 'system-connector-.test', + actionTypeId: '.test', + name: 'Test system action', + isPreconfigured: false, isDeprecated: false, - isSystemAction: false, - name: 'test', - config: { - foo: 'bar', - }, + isSystemAction: true, + referencedByCount: 2, }, - ], - connectorTokenClient: connectorTokenClientMock.create(), - getEventLogClient, + ]); }); - - const result = await actionsClient.getAll({ includeSystemActions: true }); - expect(result).toEqual([ - { - config: { - foo: 'bar', - }, - id: '1', - isDeprecated: false, - isMissingSecrets: false, - isPreconfigured: false, - isSystemAction: false, - name: 'test', - referencedByCount: 6, - }, - { - actionTypeId: '.slack', - id: 'testPreconfigured', - isDeprecated: false, - isPreconfigured: true, - isSystemAction: false, - name: 'test', - referencedByCount: 2, - }, - ]); - - expect(logger.warn).toHaveBeenCalledWith( - 'Error validating connector: 1, Error: [actionTypeId]: expected value of type [string] but got [undefined]' - ); }); }); diff --git a/x-pack/plugins/actions/server/application/connector/methods/get_all/get_all.ts b/x-pack/plugins/actions/server/application/connector/methods/get_all/get_all.ts index f0945c27265c7..362fcb2b77803 100644 --- a/x-pack/plugins/actions/server/application/connector/methods/get_all/get_all.ts +++ b/x-pack/plugins/actions/server/application/connector/methods/get_all/get_all.ts @@ -79,6 +79,44 @@ export async function getAll({ }); } +export async function getAllSystemActions({ + context, +}: { + context: GetAllParams['context']; +}): Promise { + try { + await context.authorization.ensureAuthorized({ operation: 'get' }); + } catch (error) { + context.auditLogger?.log( + connectorAuditEvent({ + action: ConnectorAuditAction.FIND, + error, + }) + ); + + throw error; + } + + const systemActions = context.inMemoryConnectors.filter((connector) => connector.isSystemAction); + + const transformedSystemActions = systemActions + .map((systemAction) => ({ + id: systemAction.id, + actionTypeId: systemAction.actionTypeId, + name: systemAction.name, + isPreconfigured: systemAction.isPreconfigured, + isDeprecated: isConnectorDeprecated(systemAction), + isSystemAction: systemAction.isSystemAction, + })) + .sort((a, b) => a.name.localeCompare(b.name)); + + return await injectExtraFindData({ + kibanaIndices: context.kibanaIndices, + scopedClusterClient: context.scopedClusterClient, + connectors: transformedSystemActions, + }); +} + async function injectExtraFindData({ kibanaIndices, scopedClusterClient, diff --git a/x-pack/plugins/actions/server/mocks.ts b/x-pack/plugins/actions/server/mocks.ts index 70a2cfd9f8e85..72af76ef55b9c 100644 --- a/x-pack/plugins/actions/server/mocks.ts +++ b/x-pack/plugins/actions/server/mocks.ts @@ -48,6 +48,7 @@ const createStartMock = () => { .mockReturnValue(actionsAuthorizationMock.create()), inMemoryConnectors: [], renderActionParameterTemplates: jest.fn(), + isSystemActionConnector: jest.fn(), }; return mock; }; diff --git a/x-pack/plugins/actions/server/plugin.test.ts b/x-pack/plugins/actions/server/plugin.test.ts index dd936600d7055..e6852d370f943 100644 --- a/x-pack/plugins/actions/server/plugin.test.ts +++ b/x-pack/plugins/actions/server/plugin.test.ts @@ -882,5 +882,53 @@ describe('Actions Plugin', () => { expect(pluginSetup.getActionsHealth()).toEqual({ hasPermanentEncryptionKey: true }); }); }); + + describe('isSystemActionConnector()', () => { + it('should return true if the connector is a system connector', async () => { + // coreMock.createSetup doesn't support Plugin generics + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const pluginSetup = await plugin.setup(coreSetup as any, pluginsSetup); + + pluginSetup.registerType({ + id: '.cases', + name: 'Cases', + minimumLicenseRequired: 'platinum', + supportedFeatureIds: ['alerting'], + validate: { + config: { schema: schema.object({}) }, + secrets: { schema: schema.object({}) }, + params: { schema: schema.object({}) }, + }, + isSystemActionType: true, + executor, + }); + + const pluginStart = await plugin.start(coreStart, pluginsStart); + expect(pluginStart.isSystemActionConnector('system-connector-.cases')).toBe(true); + }); + + it('should return false if the connector is not a system connector', async () => { + // coreMock.createSetup doesn't support Plugin generics + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const pluginSetup = await plugin.setup(coreSetup as any, pluginsSetup); + + pluginSetup.registerType({ + id: '.cases', + name: 'Cases', + minimumLicenseRequired: 'platinum', + supportedFeatureIds: ['alerting'], + validate: { + config: { schema: schema.object({}) }, + secrets: { schema: schema.object({}) }, + params: { schema: schema.object({}) }, + }, + isSystemActionType: true, + executor, + }); + + const pluginStart = await plugin.start(coreStart, pluginsStart); + expect(pluginStart.isSystemActionConnector('preconfiguredServerLog')).toBe(false); + }); + }); }); }); diff --git a/x-pack/plugins/actions/server/plugin.ts b/x-pack/plugins/actions/server/plugin.ts index 60a27eb04e411..ed0e7d856d587 100644 --- a/x-pack/plugins/actions/server/plugin.ts +++ b/x-pack/plugins/actions/server/plugin.ts @@ -157,6 +157,7 @@ export interface PluginStartContract { params: Params, variables: Record ): Params; + isSystemActionConnector: (connectorId: string) => boolean; } export interface ActionsPluginsSetup { @@ -586,6 +587,12 @@ export class ActionsPlugin implements Plugin renderActionParameterTemplates(actionTypeRegistry, ...args), + isSystemActionConnector: (connectorId: string): boolean => { + return !!this.inMemoryConnectors.find( + (inMemoryConnector) => + inMemoryConnector.isSystemAction && inMemoryConnector.id === connectorId + ); + }, }; } diff --git a/x-pack/plugins/alerting/common/index.ts b/x-pack/plugins/alerting/common/index.ts index 05b9e84ee2b7a..0de7d7285a955 100644 --- a/x-pack/plugins/alerting/common/index.ts +++ b/x-pack/plugins/alerting/common/index.ts @@ -39,6 +39,8 @@ export * from './rule_tags_aggregation'; export * from './iso_weekdays'; export * from './saved_objects/rules/mappings'; +export { isSystemAction } from './system_actions/is_system_action'; + export type { MaintenanceWindowModificationMetadata, DateRange, diff --git a/x-pack/plugins/alerting/common/routes/rule/apis/bulk_edit/schemas/v1.ts b/x-pack/plugins/alerting/common/routes/rule/apis/bulk_edit/schemas/v1.ts index adbebc679c218..d9c982c671956 100644 --- a/x-pack/plugins/alerting/common/routes/rule/apis/bulk_edit/schemas/v1.ts +++ b/x-pack/plugins/alerting/common/routes/rule/apis/bulk_edit/schemas/v1.ts @@ -38,7 +38,7 @@ const ruleSnoozeScheduleSchemaWithValidation = schema.object( ); const ruleActionSchema = schema.object({ - group: schema.string(), + group: schema.maybe(schema.string()), id: schema.string(), params: schema.recordOf(schema.string(), schema.any(), { defaultValue: {} }), uuid: schema.maybe(schema.string()), diff --git a/x-pack/plugins/alerting/common/routes/rule/apis/create/schemas/v1.ts b/x-pack/plugins/alerting/common/routes/rule/apis/create/schemas/v1.ts index 98d82abf62be4..498e1501ae033 100644 --- a/x-pack/plugins/alerting/common/routes/rule/apis/create/schemas/v1.ts +++ b/x-pack/plugins/alerting/common/routes/rule/apis/create/schemas/v1.ts @@ -71,7 +71,7 @@ export const actionAlertsFilterSchema = schema.object({ export const actionSchema = schema.object({ uuid: schema.maybe(schema.string()), - group: schema.string(), + group: schema.maybe(schema.string()), id: schema.string(), actionTypeId: schema.maybe(schema.string()), params: schema.recordOf(schema.string(), schema.maybe(schema.any()), { defaultValue: {} }), diff --git a/x-pack/plugins/alerting/common/routes/rule/response/schemas/v1.ts b/x-pack/plugins/alerting/common/routes/rule/response/schemas/v1.ts index 2fb82c3558cb7..daa90b84faad7 100644 --- a/x-pack/plugins/alerting/common/routes/rule/response/schemas/v1.ts +++ b/x-pack/plugins/alerting/common/routes/rule/response/schemas/v1.ts @@ -71,7 +71,7 @@ const actionAlertsFilterSchema = schema.object({ const actionSchema = schema.object({ uuid: schema.maybe(schema.string()), - group: schema.string(), + group: schema.maybe(schema.string()), id: schema.string(), connector_type_id: schema.string(), params: actionParamsSchema, diff --git a/x-pack/plugins/alerting/common/rule.ts b/x-pack/plugins/alerting/common/rule.ts index c83e8d6e42b87..9314e2e1fb9ce 100644 --- a/x-pack/plugins/alerting/common/rule.ts +++ b/x-pack/plugins/alerting/common/rule.ts @@ -82,13 +82,13 @@ export interface RuleExecutionStatus { export type RuleActionParams = SavedObjectAttributes; export type RuleActionParam = SavedObjectAttribute; -export interface RuleActionFrequency extends SavedObjectAttributes { +export interface RuleActionFrequency { summary: boolean; notifyWhen: RuleNotifyWhenType; throttle: string | null; } -export interface AlertsFilterTimeframe extends SavedObjectAttributes { +export interface AlertsFilterTimeframe { days: IsoWeekday[]; timezone: string; hours: { @@ -97,7 +97,7 @@ export interface AlertsFilterTimeframe extends SavedObjectAttributes { }; } -export interface AlertsFilter extends SavedObjectAttributes { +export interface AlertsFilter { query?: { kql: string; filters: Filter[]; @@ -121,7 +121,7 @@ export const RuleActionTypes = { export type RuleActionTypes = typeof RuleActionTypes[keyof typeof RuleActionTypes]; -export interface RuleAction { +export interface RuleDefaultAction { uuid?: string; group: string; id: string; @@ -129,9 +129,30 @@ export interface RuleAction { params: RuleActionParams; frequency?: RuleActionFrequency; alertsFilter?: AlertsFilter; - type?: typeof RuleActionTypes.DEFAULT; + type: typeof RuleActionTypes.DEFAULT; } +export interface RuleSystemAction { + uuid?: string; + id: string; + actionTypeId: string; + params: RuleActionParams; + type: typeof RuleActionTypes.SYSTEM; +} + +export type RuleAction = RuleDefaultAction | RuleSystemAction; + +/** + * TODO: Remove when all http routes and methods + * of the rules client are versioned. + * + * Actions internally (rules client methods) contains a type (RuleActionTypes). + * All APIs strip out the type from the actions. This TS type represents that. + */ +export type RuleActionResponse = Omit & { + group?: string; +}; + export interface AggregateOptions { search?: string; defaultSearchOperator?: 'AND' | 'OR'; @@ -209,6 +230,17 @@ export interface Rule { viewInAppRelativeUrl?: string; } +/** + * TODO: Remove when all http routes and methods + * of the rules client are versioned. + * + * Actions internally (rules client methods) contains a type (RuleActionTypes). + * All APIs strip out the type from the actions. This TS type represents that. + */ +export type RuleResponse = Omit, 'actions'> & { + actions: RuleActionResponse[]; +}; + export interface SanitizedAlertsFilter extends AlertsFilter { query?: { kql: string; @@ -217,18 +249,49 @@ export interface SanitizedAlertsFilter extends AlertsFilter { timeframe?: AlertsFilterTimeframe; } -export type SanitizedRuleAction = Omit & { +export type SanitizedDefaultRuleAction = Omit & { alertsFilter?: SanitizedAlertsFilter; }; +export type SanitizedRuleAction = SanitizedDefaultRuleAction | RuleSystemAction; + export type SanitizedRule = Omit< Rule, 'apiKey' | 'actions' > & { actions: SanitizedRuleAction[] }; +/** + * TODO: Remove when all http routes and methods + * of the rules client are versioned. + * + * Actions internally (rules client methods) contains a type (RuleActionTypes). + * All APIs strip out the type from the actions. This TS type represents that. + */ +export type SanitizedRuleActionResponse = Omit & { + group?: string; +}; + +export type SanitizedRuleResponse = Omit< + Rule, + 'apiKey' | 'actions' +> & { actions: SanitizedRuleActionResponse[] }; + export type ResolvedSanitizedRule = SanitizedRule & Omit; +/** + * TODO: Remove when all http routes and methods + * of the rules client are versioned. + * + * Actions internally (rules client methods) contains a type (RuleActionTypes). + * All APIs strip out the type from the actions. This TS type represents that. + */ + +export type ResolvedSanitizedRuleResponse = Omit< + ResolvedSanitizedRule, + 'actions' +> & { actions: SanitizedRuleActionResponse[] }; + export type SanitizedRuleConfig = Pick< SanitizedRule, | 'id' diff --git a/x-pack/plugins/alerting/common/system_actions/is_system_action.test.ts b/x-pack/plugins/alerting/common/system_actions/is_system_action.test.ts new file mode 100644 index 0000000000000..ee5c030c2c5dc --- /dev/null +++ b/x-pack/plugins/alerting/common/system_actions/is_system_action.test.ts @@ -0,0 +1,36 @@ +/* + * 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 { RuleSystemAction, RuleActionTypes, RuleDefaultAction } from '../rule'; +import { isSystemAction } from './is_system_action'; + +describe('isSystemAction', () => { + const defaultAction: RuleDefaultAction = { + actionTypeId: '.test', + uuid: '111', + group: 'default', + id: '1', + params: {}, + type: RuleActionTypes.DEFAULT, + }; + + const systemAction: RuleSystemAction = { + id: '1', + uuid: '123', + params: { 'not-exist': 'test' }, + actionTypeId: '.test', + type: RuleActionTypes.SYSTEM, + }; + + it('returns true if it is a system action', () => { + expect(isSystemAction(systemAction)).toBe(true); + }); + + it('returns false if it is not a system action', () => { + expect(isSystemAction(defaultAction)).toBe(false); + }); +}); diff --git a/x-pack/plugins/alerting/common/system_actions/is_system_action.ts b/x-pack/plugins/alerting/common/system_actions/is_system_action.ts new file mode 100644 index 0000000000000..ae9958b20b6b8 --- /dev/null +++ b/x-pack/plugins/alerting/common/system_actions/is_system_action.ts @@ -0,0 +1,17 @@ +/* + * 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 { AsApiContract } from '@kbn/actions-plugin/common'; +import { RuleAction, RuleSystemAction, RuleActionTypes } from '../rule'; + +type GetSystemActionType = T extends RuleAction + ? RuleSystemAction + : AsApiContract; + +export const isSystemAction = ( + action: RuleAction | AsApiContract +): action is GetSystemActionType => action.type === RuleActionTypes.SYSTEM; diff --git a/x-pack/plugins/alerting/public/alert_navigation_registry/types.ts b/x-pack/plugins/alerting/public/alert_navigation_registry/types.ts index 405f040d5b3ae..a3e682fa7e30b 100644 --- a/x-pack/plugins/alerting/public/alert_navigation_registry/types.ts +++ b/x-pack/plugins/alerting/public/alert_navigation_registry/types.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { SanitizedRule } from '../../common'; +import { SanitizedRuleResponse } from '../../common'; /** * Returns information that can be used to navigate to a specific page to view the given rule. @@ -16,4 +16,4 @@ import { SanitizedRule } from '../../common'; * originally registered to {@link PluginSetupContract.registerNavigation}. * */ -export type AlertNavigationHandler = (rule: SanitizedRule) => string; +export type AlertNavigationHandler = (rule: SanitizedRuleResponse) => string; diff --git a/x-pack/plugins/alerting/public/lib/common_transformations.test.ts b/x-pack/plugins/alerting/public/lib/common_transformations.test.ts index 0a3cdcb336386..d2cbe7e962b41 100644 --- a/x-pack/plugins/alerting/public/lib/common_transformations.test.ts +++ b/x-pack/plugins/alerting/public/lib/common_transformations.test.ts @@ -16,6 +16,26 @@ const dateUpdated = new Date(dateFixed - 1000); const dateExecuted = new Date(dateFixed); describe('common_transformations', () => { + const defaultAction = { + group: 'default', + id: 'aaa', + connector_type_id: 'bbb', + params: {}, + frequency: { + summary: false, + notify_when: 'onThrottleInterval' as const, + throttle: '1m', + }, + alerts_filter: { query: { kql: 'test:1', dsl: '{}', filters: [] } }, + }; + + const systemAction = { + id: 'system-action', + uuid: '123', + connector_type_id: 'bbb', + params: {}, + }; + test('transformRule() with all optional fields', () => { const apiRule: ApiRule = { id: 'some-id', @@ -25,15 +45,7 @@ describe('common_transformations', () => { rule_type_id: 'some-rule-type', consumer: 'some-consumer', schedule: { interval: '1s' }, - actions: [ - { - connector_type_id: 'some-connector-type-id', - group: 'some group', - id: 'some-connector-id', - params: { foo: 'car', bar: [1, 2, 3] }, - uuid: '123-456', - }, - ], + actions: [defaultAction, systemAction], params: { bar: 'foo', numbers: { 1: [2, 3] } } as never, scheduled_task_id: 'some-task-id', created_by: 'created-by-user', @@ -95,22 +107,33 @@ describe('common_transformations', () => { }, next_run: dateUpdated.toISOString(), }; + expect(transformRule(apiRule)).toMatchInlineSnapshot(` Object { "actions": Array [ Object { - "actionTypeId": "some-connector-type-id", - "group": "some group", - "id": "some-connector-id", - "params": Object { - "bar": Array [ - 1, - 2, - 3, - ], - "foo": "car", + "actionTypeId": "bbb", + "alertsFilter": Object { + "query": Object { + "dsl": "{}", + "filters": Array [], + "kql": "test:1", + }, }, - "uuid": "123-456", + "frequency": Object { + "notifyWhen": "onThrottleInterval", + "summary": false, + "throttle": "1m", + }, + "group": "default", + "id": "aaa", + "params": Object {}, + }, + Object { + "actionTypeId": "bbb", + "id": "system-action", + "params": Object {}, + "uuid": "123", }, ], "alertTypeId": "some-rule-type", @@ -211,15 +234,7 @@ describe('common_transformations', () => { rule_type_id: 'some-rule-type', consumer: 'some-consumer', schedule: { interval: '1s' }, - actions: [ - { - connector_type_id: 'some-connector-type-id', - group: 'some group', - id: 'some-connector-id', - params: {}, - uuid: '123-456', - }, - ], + actions: [defaultAction, systemAction], params: {} as never, created_by: 'created-by-user', updated_by: null, @@ -279,11 +294,28 @@ describe('common_transformations', () => { Object { "actions": Array [ Object { - "actionTypeId": "some-connector-type-id", - "group": "some group", - "id": "some-connector-id", + "actionTypeId": "bbb", + "alertsFilter": Object { + "query": Object { + "dsl": "{}", + "filters": Array [], + "kql": "test:1", + }, + }, + "frequency": Object { + "notifyWhen": "onThrottleInterval", + "summary": false, + "throttle": "1m", + }, + "group": "default", + "id": "aaa", + "params": Object {}, + }, + Object { + "actionTypeId": "bbb", + "id": "system-action", "params": Object {}, - "uuid": "123-456", + "uuid": "123", }, ], "alertTypeId": "some-rule-type", diff --git a/x-pack/plugins/alerting/public/lib/common_transformations.ts b/x-pack/plugins/alerting/public/lib/common_transformations.ts index e79bed27ec3d8..b9aef496268d2 100644 --- a/x-pack/plugins/alerting/public/lib/common_transformations.ts +++ b/x-pack/plugins/alerting/public/lib/common_transformations.ts @@ -10,12 +10,19 @@ import { RuleMonitoring, Rule, RuleLastRun, - RuleAction, RuleType, + RuleActionResponse, + RuleResponse, } from '../../common'; -function transformAction(input: AsApiContract): RuleAction { - const { connector_type_id: actionTypeId, frequency, ...rest } = input; +function transformAction(input: AsApiContract): RuleActionResponse { + const { + connector_type_id: actionTypeId, + frequency, + alerts_filter: alertsFilter, + ...rest + } = input; + return { actionTypeId, ...(frequency @@ -27,6 +34,7 @@ function transformAction(input: AsApiContract): RuleAction { }, } : {}), + ...(alertsFilter && { alertsFilter }), ...rest, }; } @@ -91,7 +99,7 @@ export type ApiRule = Omit< | 'next_run' > & { execution_status: ApiRuleExecutionStatus; - actions: Array>; + actions: Array>; created_at: string; updated_at: string; rule_type_id: string; @@ -100,7 +108,7 @@ export type ApiRule = Omit< next_run?: string; }; -export function transformRule(input: ApiRule): Rule { +export function transformRule(input: ApiRule): RuleResponse { const { rule_type_id: alertTypeId, created_by: createdBy, diff --git a/x-pack/plugins/alerting/public/services/alert_api.test.ts b/x-pack/plugins/alerting/public/services/alert_api.test.ts index 62cd14a3c21db..02b1132c9d7c5 100644 --- a/x-pack/plugins/alerting/public/services/alert_api.test.ts +++ b/x-pack/plugins/alerting/public/services/alert_api.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { Rule, RuleType } from '../../common'; +import { RuleResponse, RuleType } from '../../common'; import { httpServiceMock } from '@kbn/core/public/mocks'; import { loadRule, loadRuleType, loadRuleTypes } from './alert_api'; @@ -296,7 +296,7 @@ function getApiRule() { }; } -function getRule(): Rule<{ x: number }> { +function getRule(): RuleResponse<{ x: number }> { return { id: '3d534c70-582b-11ec-8995-2b1578a3bc5d', notifyWhen: 'onActiveAlert', diff --git a/x-pack/plugins/alerting/public/services/alert_api.ts b/x-pack/plugins/alerting/public/services/alert_api.ts index 346914bb9b8ca..fc61b2dc1dde3 100644 --- a/x-pack/plugins/alerting/public/services/alert_api.ts +++ b/x-pack/plugins/alerting/public/services/alert_api.ts @@ -7,8 +7,12 @@ import { HttpSetup } from '@kbn/core/public'; import { AsApiContract } from '@kbn/actions-plugin/common'; -import { BASE_ALERTING_API_PATH, INTERNAL_BASE_ALERTING_API_PATH } from '../../common'; -import type { Rule, RuleType } from '../../common'; +import { + BASE_ALERTING_API_PATH, + INTERNAL_BASE_ALERTING_API_PATH, + RuleResponse, +} from '../../common'; +import type { RuleType } from '../../common'; import { transformRule, transformRuleType, ApiRule } from '../lib/common_transformations'; export async function loadRuleTypes({ http }: { http: HttpSetup }): Promise { @@ -35,7 +39,7 @@ export async function loadRule({ }: { http: HttpSetup; ruleId: string; -}): Promise { +}): Promise { const res = await http.get(`${INTERNAL_BASE_ALERTING_API_PATH}/rule/${ruleId}`); return transformRule(res); } diff --git a/x-pack/plugins/alerting/server/application/rule/methods/bulk_edit/bulk_edit_rules.test.ts b/x-pack/plugins/alerting/server/application/rule/methods/bulk_edit/bulk_edit_rules.test.ts index a9c9675b47640..ffbcdbb05ab59 100644 --- a/x-pack/plugins/alerting/server/application/rule/methods/bulk_edit/bulk_edit_rules.test.ts +++ b/x-pack/plugins/alerting/server/application/rule/methods/bulk_edit/bulk_edit_rules.test.ts @@ -18,7 +18,7 @@ import { import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks'; import { ruleTypeRegistryMock } from '../../../../rule_type_registry.mock'; import { alertingAuthorizationMock } from '../../../../authorization/alerting_authorization.mock'; -import { RecoveredActionGroup, RuleTypeParams } from '../../../../../common'; +import { RecoveredActionGroup, RuleActionTypes, RuleTypeParams } from '../../../../../common'; import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks'; import { actionsAuthorizationMock } from '@kbn/actions-plugin/server/mocks'; import { AlertingAuthorization } from '../../../../authorization/alerting_authorization'; @@ -26,7 +26,6 @@ import { ActionsAuthorization, ActionsClient } from '@kbn/actions-plugin/server' import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; import { getBeforeSetup, setGlobalDate } from '../../../../rules_client/tests/lib'; import { bulkMarkApiKeysForInvalidation } from '../../../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation'; -import { NormalizedAlertAction } from '../../../../rules_client/types'; import { enabledRule1, enabledRule2, @@ -36,6 +35,9 @@ import { import { migrateLegacyActions } from '../../../../rules_client/lib'; import { migrateLegacyActionsMock } from '../../../../rules_client/lib/siem_legacy_actions/retrieve_migrated_legacy_actions.mock'; import { ConnectorAdapterRegistry } from '../../../../connector_adapters/connector_adapter_registry'; +import { ConnectorAdapter } from '../../../../connector_adapters/types'; +import { RuleAttributes } from '../../../../data/rule/types'; +import { SavedObject } from '@kbn/core/server'; jest.mock('../../../../rules_client/lib/siem_legacy_actions/migrate_legacy_actions', () => { return { @@ -104,12 +106,13 @@ const rulesClientParams: jest.Mocked = { isAuthenticationTypeAPIKey: isAuthenticationTypeApiKeyMock, getAuthenticationAPIKey: getAuthenticationApiKeyMock, connectorAdapterRegistry: new ConnectorAdapterRegistry(), + isSystemAction: jest.fn(), }; const paramsModifier = jest.fn(); const MOCK_API_KEY = Buffer.from('123:abc').toString('base64'); -beforeEach(() => { +beforeEach(async () => { getBeforeSetup(rulesClientParams, taskManager, ruleTypeRegistry); (auditLogger.log as jest.Mock).mockClear(); }); @@ -248,6 +251,9 @@ describe('bulkEdit()', () => { }); (migrateLegacyActions as jest.Mock).mockResolvedValue(migrateLegacyActionsMock); + + rulesClientParams.isSystemAction.mockImplementation((id: string) => id === 'system_action-id'); + actionsClient.isSystemAction.mockImplementation((id: string) => id === 'system_action-id'); }); describe('tags operations', () => { @@ -532,6 +538,14 @@ describe('bulkEdit()', () => { }); describe('actions operations', () => { + const connectorAdapter: ConnectorAdapter = { + connectorTypeId: '.test', + ruleActionParamsSchema: schema.object({ foo: schema.string() }), + buildActionParams: jest.fn(), + }; + + rulesClientParams.connectorAdapterRegistry.register(connectorAdapter); + beforeEach(() => { mockCreatePointInTimeFinderAsInternalUser({ saved_objects: [existingDecryptedRule], @@ -541,7 +555,7 @@ describe('bulkEdit()', () => { test('should add uuid to new actions', async () => { const existingAction = { frequency: { - notifyWhen: 'onActiveAlert', + notifyWhen: 'onActiveAlert' as const, summary: false, throttle: null, }, @@ -549,26 +563,31 @@ describe('bulkEdit()', () => { id: '1', params: {}, uuid: '111', + type: RuleActionTypes.DEFAULT, }; + const newAction = { frequency: { - notifyWhen: 'onActiveAlert', + notifyWhen: 'onActiveAlert' as const, summary: false, throttle: null, }, group: 'default', id: '2', params: {}, + type: RuleActionTypes.DEFAULT, }; + const newAction2 = { frequency: { - notifyWhen: 'onActiveAlert', + notifyWhen: 'onActiveAlert' as const, summary: false, throttle: null, }, group: 'default', id: '3', params: {}, + type: RuleActionTypes.DEFAULT, }; unsecuredSavedObjectsClient.bulkCreate.mockResolvedValue({ @@ -581,10 +600,12 @@ describe('bulkEdit()', () => { { ...existingAction, actionRef: 'action_0', + actionTypeId: 'test-0', }, { ...newAction, actionRef: 'action_1', + actionTypeId: 'test-1', uuid: '222', }, ], @@ -611,7 +632,7 @@ describe('bulkEdit()', () => { { field: 'actions', operation: 'add', - value: [existingAction, newAction, newAction2] as NormalizedAlertAction[], + value: [existingAction, newAction, newAction2], }, ], }); @@ -673,7 +694,10 @@ describe('bulkEdit()', () => { ...existingRule.attributes.executionStatus, lastExecutionDate: new Date(existingRule.attributes.executionStatus.lastExecutionDate), }, - actions: [existingAction, { ...newAction, uuid: '222' }], + actions: [ + { ...existingAction, type: RuleActionTypes.DEFAULT, actionTypeId: 'test-0' }, + { ...newAction, uuid: '222', type: RuleActionTypes.DEFAULT, actionTypeId: 'test-1' }, + ], id: existingRule.id, snoozeSchedule: [], }); @@ -704,6 +728,7 @@ describe('bulkEdit()', () => { params: { message: 'Rule {{context.rule.name}} generated {{state.signals_count}} alerts', }, + type: RuleActionTypes.DEFAULT, }, ], }, @@ -748,9 +773,10 @@ describe('bulkEdit()', () => { shouldWrite: true, }, }); + const existingAction = { frequency: { - notifyWhen: 'onActiveAlert', + notifyWhen: 'onActiveAlert' as const, summary: false, throttle: null, }, @@ -770,10 +796,11 @@ describe('bulkEdit()', () => { timezone: 'UTC', }, }, + type: RuleActionTypes.DEFAULT, }; const newAction = { frequency: { - notifyWhen: 'onActiveAlert', + notifyWhen: 'onActiveAlert' as const, summary: false, throttle: null, }, @@ -782,6 +809,7 @@ describe('bulkEdit()', () => { params: {}, uuid: '222', alertsFilter: { query: { kql: 'test:1', dsl: 'test', filters: [] } }, + type: RuleActionTypes.DEFAULT, }; unsecuredSavedObjectsClient.bulkCreate.mockResolvedValue({ @@ -827,7 +855,7 @@ describe('bulkEdit()', () => { { field: 'actions', operation: 'add', - value: [existingAction, newAction] as NormalizedAlertAction[], + value: [existingAction, newAction], }, ], }); @@ -905,6 +933,541 @@ describe('bulkEdit()', () => { snoozeSchedule: [], }); }); + + test('should add system and default actions', async () => { + const newAction = { + frequency: { + notifyWhen: 'onActiveAlert' as const, + summary: false, + throttle: null, + }, + group: 'default', + id: '1', + params: {}, + type: RuleActionTypes.DEFAULT, + }; + + const newAction2 = { + id: 'system_action-id', + params: {}, + type: RuleActionTypes.SYSTEM, + }; + + unsecuredSavedObjectsClient.bulkCreate.mockResolvedValue({ + saved_objects: [ + { + ...existingRule, + attributes: { + ...existingRule.attributes, + actions: [ + { + ...newAction, + actionRef: 'action_0', + actionTypeId: 'test-1', + uuid: '222', + }, + { + ...newAction2, + actionRef: 'system_action:system_action-id', + actionTypeId: 'test-2', + uuid: '222', + }, + ], + }, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + ], + }, + ], + }); + + actionsClient.getBulk.mockResolvedValue([ + { + id: '1', + actionTypeId: 'test-1', + config: {}, + isMissingSecrets: false, + name: 'test default connector', + isPreconfigured: false, + isDeprecated: false, + isSystemAction: false, + }, + { + id: 'system_action-id', + actionTypeId: 'test-2', + config: {}, + isMissingSecrets: false, + name: 'system action connector', + isPreconfigured: false, + isDeprecated: false, + isSystemAction: true, + }, + ]); + + const result = await rulesClient.bulkEdit({ + filter: '', + operations: [ + { + field: 'actions', + operation: 'add', + value: [newAction, newAction2], + }, + ], + }); + + expect(unsecuredSavedObjectsClient.bulkCreate).toHaveBeenCalledWith( + [ + { + ...existingRule, + attributes: { + ...existingRule.attributes, + actions: [ + { + actionRef: 'action_0', + actionTypeId: 'test-1', + frequency: { notifyWhen: 'onActiveAlert', summary: false, throttle: null }, + group: 'default', + params: {}, + uuid: '103', + }, + { + actionRef: 'system_action:system_action-id', + actionTypeId: 'test-2', + params: {}, + uuid: '104', + }, + ], + apiKey: null, + apiKeyOwner: null, + apiKeyCreatedByUser: null, + meta: { versionApiKeyLastmodified: 'v8.2.0' }, + name: 'my rule name', + enabled: false, + updatedAt: '2019-02-12T21:01:22.479Z', + updatedBy: 'elastic', + tags: ['foo'], + revision: 1, + }, + references: [{ id: '1', name: 'action_0', type: 'action' }], + }, + ], + { overwrite: true } + ); + + expect(result.rules[0]).toEqual({ + ...omit(existingRule.attributes, 'legacyId'), + createdAt: new Date(existingRule.attributes.createdAt), + updatedAt: new Date(existingRule.attributes.updatedAt), + executionStatus: { + ...existingRule.attributes.executionStatus, + lastExecutionDate: new Date(existingRule.attributes.executionStatus.lastExecutionDate), + }, + actions: [ + { ...newAction, actionTypeId: 'test-1', uuid: '222' }, + { ...newAction2, actionTypeId: 'test-2', uuid: '222' }, + ], + id: existingRule.id, + snoozeSchedule: [], + }); + }); + + test('should construct the refs correctly and not persist the type of the action', async () => { + const newAction = { + frequency: { + notifyWhen: 'onActiveAlert' as const, + summary: false, + throttle: null, + }, + group: 'default', + id: '1', + params: {}, + type: RuleActionTypes.DEFAULT, + }; + + const newAction2 = { + id: 'system_action-id', + params: {}, + type: RuleActionTypes.SYSTEM, + }; + + unsecuredSavedObjectsClient.bulkCreate.mockResolvedValue({ + saved_objects: [ + { + ...existingRule, + attributes: { + ...existingRule.attributes, + actions: [ + { + ...newAction, + actionRef: 'action_0', + actionTypeId: 'test-1', + uuid: '222', + }, + { + ...newAction2, + actionRef: 'system_action:system_action-id', + actionTypeId: 'test-2', + uuid: '222', + }, + ], + }, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + ], + }, + ], + }); + + actionsClient.getBulk.mockResolvedValue([ + { + id: '1', + actionTypeId: 'test-1', + config: {}, + isMissingSecrets: false, + name: 'test default connector', + isPreconfigured: false, + isDeprecated: false, + isSystemAction: false, + }, + { + id: 'system_action-id', + actionTypeId: 'test-2', + config: {}, + isMissingSecrets: false, + name: 'system action connector', + isPreconfigured: false, + isDeprecated: false, + isSystemAction: true, + }, + ]); + + await rulesClient.bulkEdit({ + filter: '', + operations: [ + { + field: 'actions', + operation: 'add', + value: [newAction, newAction2], + }, + ], + }); + + const rule = unsecuredSavedObjectsClient.bulkCreate.mock.calls[0][0] as Array< + SavedObject + >; + + expect(rule[0].attributes.actions).toEqual([ + { + actionRef: 'action_0', + actionTypeId: 'test-1', + frequency: { notifyWhen: 'onActiveAlert', summary: false, throttle: null }, + group: 'default', + params: {}, + uuid: '105', + }, + { + actionRef: 'system_action:system_action-id', + actionTypeId: 'test-2', + params: {}, + uuid: '106', + }, + ]); + }); + + test('should add the actions type to the response correctly', async () => { + const newAction = { + frequency: { + notifyWhen: 'onActiveAlert' as const, + summary: false, + throttle: null, + }, + group: 'default', + id: '1', + params: {}, + type: RuleActionTypes.DEFAULT, + }; + + const newAction2 = { + id: 'system_action-id', + params: {}, + type: RuleActionTypes.SYSTEM, + }; + + unsecuredSavedObjectsClient.bulkCreate.mockResolvedValue({ + saved_objects: [ + { + ...existingRule, + attributes: { + ...existingRule.attributes, + actions: [ + { + ...newAction, + actionRef: 'action_0', + actionTypeId: 'test-1', + uuid: '222', + }, + { + ...newAction2, + actionRef: 'system_action:system_action-id', + actionTypeId: 'test-2', + uuid: '222', + }, + ], + }, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + ], + }, + ], + }); + + actionsClient.getBulk.mockResolvedValue([ + { + id: '1', + actionTypeId: 'test-1', + config: {}, + isMissingSecrets: false, + name: 'test default connector', + isPreconfigured: false, + isDeprecated: false, + isSystemAction: false, + }, + { + id: 'system_action-id', + actionTypeId: 'test-2', + config: {}, + isMissingSecrets: false, + name: 'system action connector', + isPreconfigured: false, + isDeprecated: false, + isSystemAction: true, + }, + ]); + + const result = await rulesClient.bulkEdit({ + filter: '', + operations: [ + { + field: 'actions', + operation: 'add', + value: [newAction, newAction2], + }, + ], + }); + + expect(result.rules[0].actions).toEqual([ + { ...newAction, actionTypeId: 'test-1', uuid: '222' }, + { ...newAction2, actionTypeId: 'test-2', uuid: '222' }, + ]); + }); + + it('should return an error if the system action does not exist', async () => { + const action = { + id: 'system_action-id', + uuid: '123', + params: {}, + type: RuleActionTypes.SYSTEM, + }; + + actionsClient.isSystemAction.mockReturnValue(false); + actionsClient.getBulk.mockResolvedValue([ + { + id: 'system_action-id', + actionTypeId: 'test-2', + config: {}, + isMissingSecrets: false, + name: 'system action connector', + isPreconfigured: false, + isDeprecated: false, + isSystemAction: true, + }, + ]); + + const result = await rulesClient.bulkEdit({ + filter: '', + operations: [ + { + field: 'actions', + operation: 'add', + value: [action], + }, + ], + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "errors": Array [ + Object { + "message": "Action system_action-id is not a system action", + "rule": Object { + "id": "1", + "name": "my rule name", + }, + }, + ], + "rules": Array [], + "skipped": Array [], + "total": 1, + } + `); + + expect(actionsClient.getBulk).toBeCalledWith({ + ids: ['system_action-id'], + throwIfSystemAction: false, + }); + }); + + it('should throw an error if the system action contains the frequency', async () => { + const action = { + id: 'system_action-id', + uuid: '123', + params: {}, + frequency: { + notifyWhen: 'onActiveAlert' as const, + summary: false, + throttle: null, + }, + type: RuleActionTypes.SYSTEM, + }; + + actionsClient.isSystemAction.mockReturnValue(true); + actionsClient.getBulk.mockResolvedValue([ + { + id: 'system_action-id', + actionTypeId: 'test-2', + config: {}, + isMissingSecrets: false, + name: 'system action connector', + isPreconfigured: false, + isDeprecated: false, + isSystemAction: true, + }, + ]); + + await expect( + rulesClient.bulkEdit({ + filter: '', + operations: [ + { + field: 'actions', + operation: 'add', + value: [action], + }, + ], + }) + ).rejects.toMatchInlineSnapshot(` + [Error: Error validating bulk edit rules operations - [0]: types that failed validation: + - [0.0.field]: expected value to equal [tags] + - [0.1.value.0]: types that failed validation: + - [0.value.0.0.group]: expected value of type [string] but got [undefined] + - [0.value.0.1.frequency]: definition for this key is missing + - [0.2.operation]: expected value to equal [set] + - [0.3.operation]: expected value to equal [set] + - [0.4.operation]: expected value to equal [set] + - [0.5.operation]: expected value to equal [set] + - [0.6.operation]: expected value to equal [delete] + - [0.7.operation]: expected value to equal [set]] + `); + }); + + it('should throw an error if the system action contains the alertsFilter', async () => { + const action = { + id: 'system_action-id', + uuid: '123', + params: {}, + alertsFilter: { + query: { kql: 'test:1', filters: [] }, + }, + type: RuleActionTypes.SYSTEM, + }; + + actionsClient.isSystemAction.mockReturnValue(true); + actionsClient.getBulk.mockResolvedValue([ + { + id: 'system_action-id', + actionTypeId: 'test-2', + config: {}, + isMissingSecrets: false, + name: 'system action connector', + isPreconfigured: false, + isDeprecated: false, + isSystemAction: true, + }, + ]); + + await expect( + rulesClient.bulkEdit({ + filter: '', + operations: [ + { + field: 'actions', + operation: 'add', + value: [action], + }, + ], + }) + ).rejects.toMatchInlineSnapshot(` + [Error: Error validating bulk edit rules operations - [0]: types that failed validation: + - [0.0.field]: expected value to equal [tags] + - [0.1.value.0]: types that failed validation: + - [0.value.0.0.group]: expected value of type [string] but got [undefined] + - [0.value.0.1.alertsFilter]: definition for this key is missing + - [0.2.operation]: expected value to equal [set] + - [0.3.operation]: expected value to equal [set] + - [0.4.operation]: expected value to equal [set] + - [0.5.operation]: expected value to equal [set] + - [0.6.operation]: expected value to equal [delete] + - [0.7.operation]: expected value to equal [set]] + `); + }); + + it('should throw an error if the default action does not contain the group', async () => { + const action = { + id: '1', + params: {}, + type: RuleActionTypes.DEFAULT, + }; + + actionsClient.isSystemAction.mockReturnValue(false); + + await expect( + rulesClient.bulkEdit({ + filter: '', + operations: [ + { + field: 'actions', + operation: 'add', + // @ts-expect-error: group is missing + value: [action], + }, + ], + }) + ).rejects.toMatchInlineSnapshot(` + [Error: Error validating bulk edit rules operations - [0]: types that failed validation: + - [0.0.field]: expected value to equal [tags] + - [0.1.value.0]: types that failed validation: + - [0.value.0.0.group]: expected value of type [string] but got [undefined] + - [0.value.0.1.type]: expected value to equal [system] + - [0.2.operation]: expected value to equal [set] + - [0.3.operation]: expected value to equal [set] + - [0.4.operation]: expected value to equal [set] + - [0.5.operation]: expected value to equal [set] + - [0.6.operation]: expected value to equal [delete] + - [0.7.operation]: expected value to equal [set]] + `); + }); }); describe('index pattern operations', () => { @@ -962,7 +1525,13 @@ describe('bulkEdit()', () => { const result = await rulesClient.bulkEdit({ filter: '', - operations: [], + operations: [ + { + field: 'tags', + operation: 'add', + value: ['test-tag'], + }, + ], paramsModifier, }); @@ -1031,7 +1600,13 @@ describe('bulkEdit()', () => { const result = await rulesClient.bulkEdit({ filter: '', - operations: [], + operations: [ + { + field: 'tags', + operation: 'add', + value: ['test-tag'], + }, + ], paramsModifier, }); @@ -1065,7 +1640,13 @@ describe('bulkEdit()', () => { const result = await rulesClient.bulkEdit({ filter: '', - operations: [], + operations: [ + { + field: 'tags', + operation: 'add', + value: ['test-tag'], + }, + ], paramsModifier, }); @@ -2434,7 +3015,13 @@ describe('bulkEdit()', () => { const result = await rulesClient.bulkEdit({ filter: '', - operations: [], + operations: [ + { + field: 'tags', + operation: 'add', + value: ['test-1'], + }, + ], paramsModifier: async (params) => { params.index = ['test-index-*']; @@ -2569,6 +3156,50 @@ describe('bulkEdit()', () => { expect(validateScheduleLimit).toHaveBeenCalledTimes(1); }); + + test('should not validate scheduling on system actions', async () => { + mockCreatePointInTimeFinderAsInternalUser({ + saved_objects: [ + { + ...existingDecryptedRule, + attributes: { + ...existingDecryptedRule.attributes, + actions: [ + { + actionRef: 'action_0', + actionTypeId: 'test', + params: {}, + uuid: '111', + type: RuleActionTypes.SYSTEM, + }, + ], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ] as any, + }, + ], + }); + + const result = await rulesClient.bulkEdit({ + operations: [ + { + field: 'schedule', + operation: 'set', + value: { interval: '10m' }, + }, + ], + }); + + expect(result.errors).toHaveLength(0); + expect(result.rules).toHaveLength(1); + }); }); describe('paramsModifier', () => { @@ -2602,7 +3233,13 @@ describe('bulkEdit()', () => { const result = await rulesClient.bulkEdit({ filter: '', - operations: [], + operations: [ + { + field: 'tags', + operation: 'add', + value: ['test-1'], + }, + ], paramsModifier: async (params) => { params.index = ['test-index-*']; diff --git a/x-pack/plugins/alerting/server/application/rule/methods/bulk_edit/bulk_edit_rules.ts b/x-pack/plugins/alerting/server/application/rule/methods/bulk_edit/bulk_edit_rules.ts index d76162696ead2..ee6e473261819 100644 --- a/x-pack/plugins/alerting/server/application/rule/methods/bulk_edit/bulk_edit_rules.ts +++ b/x-pack/plugins/alerting/server/application/rule/methods/bulk_edit/bulk_edit_rules.ts @@ -16,6 +16,9 @@ import { SavedObjectsFindResult, SavedObjectsUpdateResponse, } from '@kbn/core/server'; +import { validateSystemActions } from '../../../../lib/validate_system_actions'; +import { RuleActionTypes, RuleDefaultAction, RuleSystemAction } from '../../../../../common'; +import { isSystemAction } from '../../../../../common/system_actions/is_system_action'; import { BulkActionSkipResult } from '../../../../../common/bulk_edit'; import { RuleTypeRegistry } from '../../../../types'; import { @@ -32,7 +35,6 @@ import { retryIfBulkEditConflicts, applyBulkEditOperation, buildKueryNodeFilter, - injectReferencesIntoActions, getBulkSnooze, getBulkUnsnooze, verifySnoozeScheduleLimit, @@ -78,6 +80,7 @@ import { transformRuleDomainToRule, } from '../../transforms'; import { validateScheduleLimit } from '../get_schedule_frequency'; +import { bulkEditOperationsSchema } from './schemas'; const isValidInterval = (interval: string | undefined): interval is string => { return interval !== undefined; @@ -115,8 +118,15 @@ export async function bulkEditRules( context: RulesClientContext, options: BulkEditOptions ): Promise> { + try { + bulkEditOperationsSchema.validate(options.operations); + } catch (error) { + throw Boom.badRequest(`Error validating bulk edit rules operations - ${error.message}`); + } + const queryFilter = (options as BulkEditOptionsFilter).filter; const ids = (options as BulkEditOptionsIds).ids; + const actionsClient = await context.getActionsClient(); if (ids && queryFilter) { throw Boom.badRequest( @@ -231,13 +241,17 @@ export async function bulkEditRules( // fix the type cast from SavedObjectsBulkUpdateObject to SavedObjectsBulkUpdateObject // when we are doing the bulk create and this should fix itself const ruleType = context.ruleTypeRegistry.get(attributes.alertTypeId!); - const ruleDomain = transformRuleAttributesToRuleDomain(attributes as RuleAttributes, { - id, - logger: context.logger, - ruleType, - references, - omitGeneratedValues: false, - }); + const ruleDomain = transformRuleAttributesToRuleDomain( + attributes as RuleAttributes, + { + id, + logger: context.logger, + ruleType, + references, + omitGeneratedValues: false, + }, + (connectorId: string) => actionsClient.isSystemAction(connectorId) + ); try { ruleDomainSchema.validate(ruleDomain); } catch (e) { @@ -458,12 +472,6 @@ async function updateRuleAttributesAndParamsInMemory( rule.references = migratedActions.resultedReferences; } - const ruleActions = injectReferencesIntoActions( - rule.id, - rule.attributes.actions || [], - rule.references || [] - ); - const ruleDomain: RuleDomain = transformRuleAttributesToRuleDomain( rule.attributes, { @@ -471,7 +479,8 @@ async function updateRuleAttributesAndParamsInMemory( logger: context.logger, ruleType: context.ruleTypeRegistry.get(rule.attributes.alertTypeId), references: rule.references, - } + }, + context.isSystemAction ); const { @@ -483,7 +492,7 @@ async function updateRuleAttributesAndParamsInMemory( context, operations, rule: ruleDomain, - ruleActions, + ruleActions: ruleDomain.actions, ruleType, }); @@ -617,6 +626,8 @@ async function getUpdatedAttributesFromOperations({ ruleActions: RuleDomain['actions']; ruleType: RuleType; }) { + const actionsClient = await context.getActionsClient(); + let updatedRule = cloneDeep(rule); let updatedRuleActions = ruleActions; let hasUpdateApiKeyOperation = false; @@ -634,6 +645,16 @@ async function getUpdatedAttributesFromOperations({ value: addGeneratedActionValues(operation.value), }; + const systemActions = operation.value.filter( + (action): action is RuleSystemAction => action.type === RuleActionTypes.SYSTEM + ); + + await validateSystemActions({ + actionsClient, + connectorAdapterRegistry: context.connectorAdapterRegistry, + systemActions, + }); + try { await validateActions(context, ruleType, { ...updatedRule, @@ -662,6 +683,7 @@ async function getUpdatedAttributesFromOperations({ break; } + case 'snoozeSchedule': { // Silently skip adding snooze or snooze schedules on security // rules until we implement snoozing of their rules @@ -672,22 +694,26 @@ async function getUpdatedAttributesFromOperations({ isAttributesUpdateSkipped = false; break; } + if (operation.operation === 'set') { const snoozeAttributes = getBulkSnooze( updatedRule, operation.value as RuleSnoozeSchedule ); + try { verifySnoozeScheduleLimit(snoozeAttributes.snoozeSchedule); } catch (error) { throw Error(`Error updating rule: could not add snooze - ${error.message}`); } + updatedRule = { ...updatedRule, muteAll: snoozeAttributes.muteAll, snoozeSchedule: snoozeAttributes.snoozeSchedule as RuleDomain['snoozeSchedule'], }; } + if (operation.operation === 'delete') { const idsToDelete = operation.value && [...operation.value]; if (idsToDelete?.length === 0) { @@ -704,18 +730,22 @@ async function getUpdatedAttributesFromOperations({ snoozeSchedule: snoozeAttributes.snoozeSchedule as RuleDomain['snoozeSchedule'], }; } + isAttributesUpdateSkipped = false; break; } + case 'apiKey': { hasUpdateApiKeyOperation = true; isAttributesUpdateSkipped = false; break; } + default: { if (operation.field === 'schedule') { validateScheduleOperation(operation.value, updatedRule.actions, rule.id); } + const { modifiedAttributes, isAttributeModified } = applyBulkEditOperation( operation, updatedRule @@ -780,8 +810,11 @@ function validateScheduleOperation( ): void { const scheduleInterval = parseDuration(schedule.interval); const actionsWithInvalidThrottles = []; + const actionsWithoutSystemActions = actions.filter( + (action): action is RuleDefaultAction => !isSystemAction(action) + ); - for (const action of actions) { + for (const action of actionsWithoutSystemActions) { // check for actions throttled shorter than the rule schedule if ( action.frequency?.notifyWhen === ruleNotifyWhen.THROTTLE && diff --git a/x-pack/plugins/alerting/server/application/rule/methods/bulk_edit/schemas/bulk_edit_rules_option_schemas.ts b/x-pack/plugins/alerting/server/application/rule/methods/bulk_edit/schemas/bulk_edit_rules_option_schemas.ts index f80d63210cf4a..e8ecb18af52b0 100644 --- a/x-pack/plugins/alerting/server/application/rule/methods/bulk_edit/schemas/bulk_edit_rules_option_schemas.ts +++ b/x-pack/plugins/alerting/server/application/rule/methods/bulk_edit/schemas/bulk_edit_rules_option_schemas.ts @@ -5,8 +5,9 @@ * 2.0. */ import { schema } from '@kbn/config-schema'; +import { RuleActionTypes } from '../../../../../../common'; import { rRuleRequestSchema } from '../../../../r_rule/schemas'; -import { notifyWhenSchema } from '../../../schemas'; +import { notifyWhenSchema, actionAlertsFilterSchema } from '../../../schemas'; import { validateDuration } from '../../../validation'; import { validateSnoozeSchedule } from '../validation'; @@ -26,7 +27,7 @@ const bulkEditRuleSnoozeScheduleSchemaWithValidation = schema.object( { validate: validateSnoozeSchedule } ); -const bulkEditActionSchema = schema.object({ +const bulkEditDefaultActionSchema = schema.object({ group: schema.string(), id: schema.string(), params: schema.recordOf(schema.string(), schema.any(), { defaultValue: {} }), @@ -38,6 +39,15 @@ const bulkEditActionSchema = schema.object({ notifyWhen: notifyWhenSchema, }) ), + alertsFilter: schema.maybe(actionAlertsFilterSchema), + type: schema.literal(RuleActionTypes.DEFAULT), +}); + +export const bulkEditSystemActionSchema = schema.object({ + id: schema.string(), + params: schema.recordOf(schema.string(), schema.maybe(schema.any()), { defaultValue: {} }), + uuid: schema.maybe(schema.string()), + type: schema.literal(RuleActionTypes.SYSTEM), }); const bulkEditTagSchema = schema.object({ @@ -49,7 +59,7 @@ const bulkEditTagSchema = schema.object({ const bulkEditActionsSchema = schema.object({ operation: schema.oneOf([schema.literal('add'), schema.literal('set')]), field: schema.literal('actions'), - value: schema.arrayOf(bulkEditActionSchema), + value: schema.arrayOf(schema.oneOf([bulkEditDefaultActionSchema, bulkEditSystemActionSchema])), }); const bulkEditScheduleSchema = schema.object({ diff --git a/x-pack/plugins/alerting/server/application/rule/methods/create/create_rule.test.ts b/x-pack/plugins/alerting/server/application/rule/methods/create/create_rule.test.ts index db330af9ac131..ace32e504cdf5 100644 --- a/x-pack/plugins/alerting/server/application/rule/methods/create/create_rule.test.ts +++ b/x-pack/plugins/alerting/server/application/rule/methods/create/create_rule.test.ts @@ -24,10 +24,12 @@ import { ruleNotifyWhen } from '../../constants'; import { TaskStatus } from '@kbn/task-manager-plugin/server'; import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; import { getBeforeSetup, setGlobalDate } from '../../../../rules_client/tests/lib'; -import { RecoveredActionGroup } from '../../../../../common'; +import { RecoveredActionGroup, RuleActionTypes, RuleSystemAction } from '../../../../../common'; import { bulkMarkApiKeysForInvalidation } from '../../../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation'; import { getRuleExecutionStatusPending, getDefaultMonitoring } from '../../../../lib'; import { ConnectorAdapterRegistry } from '../../../../connector_adapters/connector_adapter_registry'; +import { ConnectorAdapter } from '../../../../connector_adapters/types'; +import { RuleDomain } from '../../types'; jest.mock('../../../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation', () => ({ bulkMarkApiKeysForInvalidation: jest.fn(), @@ -60,6 +62,7 @@ const authorization = alertingAuthorizationMock.create(); const actionsAuthorization = actionsAuthorizationMock.create(); const auditLogger = auditLoggerMock.create(); const internalSavedObjectsRepository = savedObjectsRepositoryMock.create(); +const connectorAdapterRegistry = new ConnectorAdapterRegistry(); const kibanaVersion = 'v8.0.0'; const rulesClientParams: jest.Mocked = { @@ -83,7 +86,8 @@ const rulesClientParams: jest.Mocked = { minimumScheduleInterval: { value: '1m', enforce: false }, isAuthenticationTypeAPIKey: jest.fn(), getAuthenticationAPIKey: jest.fn(), - connectorAdapterRegistry: new ConnectorAdapterRegistry(), + connectorAdapterRegistry, + isSystemAction: jest.fn(), }; beforeEach(() => { @@ -117,6 +121,7 @@ function getMockData(overwrites: Record = {}): CreateRuleParams params: { foo: true, }, + type: RuleActionTypes.DEFAULT, }, ], ...overwrites, @@ -150,6 +155,9 @@ describe('create()', () => { isSystemAction: false, }, ]); + + actionsClient.isSystemAction.mockImplementation((id: string) => id === 'system_action-id'); + taskManager.schedule.mockResolvedValue({ id: 'task-123', taskType: 'alerting:123', @@ -163,6 +171,7 @@ describe('create()', () => { params: {}, ownerId: null, }); + rulesClientParams.getActionsClient.mockResolvedValue(actionsClient); }); @@ -189,6 +198,7 @@ describe('create()', () => { group: 'default', actionRef: 'action_0', actionTypeId: 'test', + uuid: 'test-uuid', params: { foo: true, }, @@ -343,12 +353,14 @@ describe('create()', () => { group: 'default', actionRef: 'action_0', actionTypeId: 'test', + uuid: 'test-uuid', params: { foo: true, }, }, ], }; + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: '1', type: 'alert', @@ -365,6 +377,7 @@ describe('create()', () => { }, ], }); + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: '1', type: 'alert', @@ -382,13 +395,16 @@ describe('create()', () => { }, ], }); + const result = await rulesClient.create({ data }); + expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ entity: 'rule', consumer: 'bar', operation: 'create', ruleTypeId: '123', }); + expect(result).toMatchInlineSnapshot(` Object { "actions": Array [ @@ -399,6 +415,8 @@ describe('create()', () => { "params": Object { "foo": true, }, + "type": "default", + "uuid": "test-uuid", }, ], "alertTypeId": "123", @@ -574,6 +592,7 @@ describe('create()', () => { group: 'default', actionRef: 'action_0', actionTypeId: 'test', + uuid: 'test-uuid', params: { foo: true, }, @@ -638,6 +657,7 @@ describe('create()', () => { group: 'default', actionRef: 'action_0', actionTypeId: 'test', + uuid: 'test-uuid', params: { foo: true, }, @@ -742,6 +762,7 @@ describe('create()', () => { params: { foo: true, }, + type: RuleActionTypes.DEFAULT, }, { group: 'default', @@ -749,6 +770,7 @@ describe('create()', () => { params: { foo: true, }, + type: RuleActionTypes.DEFAULT, }, { group: 'default', @@ -756,6 +778,7 @@ describe('create()', () => { params: { foo: true, }, + type: RuleActionTypes.DEFAULT, }, ], }); @@ -815,6 +838,7 @@ describe('create()', () => { group: 'default', actionRef: 'action_0', actionTypeId: 'test', + uuid: 'test-uuid', params: { foo: true, }, @@ -823,6 +847,7 @@ describe('create()', () => { group: 'default', actionRef: 'action_1', actionTypeId: 'test', + uuid: 'test-uuid-1', params: { foo: true, }, @@ -831,6 +856,7 @@ describe('create()', () => { group: 'default', actionRef: 'action_2', actionTypeId: 'test2', + uuid: 'test-uuid-2', params: { foo: true, }, @@ -875,6 +901,8 @@ describe('create()', () => { "params": Object { "foo": true, }, + "type": "default", + "uuid": "test-uuid", }, Object { "actionTypeId": "test", @@ -883,6 +911,8 @@ describe('create()', () => { "params": Object { "foo": true, }, + "type": "default", + "uuid": "test-uuid-1", }, Object { "actionTypeId": "test2", @@ -891,6 +921,8 @@ describe('create()', () => { "params": Object { "foo": true, }, + "type": "default", + "uuid": "test-uuid-2", }, ], "alertTypeId": "123", @@ -923,6 +955,7 @@ describe('create()', () => { params: { foo: true, }, + type: RuleActionTypes.DEFAULT, }, { group: 'default', @@ -930,6 +963,7 @@ describe('create()', () => { params: { foo: true, }, + type: RuleActionTypes.DEFAULT, }, { group: 'default', @@ -937,6 +971,7 @@ describe('create()', () => { params: { foo: true, }, + type: RuleActionTypes.DEFAULT, }, ], }); @@ -1016,6 +1051,7 @@ describe('create()', () => { group: 'default', actionRef: 'action_0', actionTypeId: 'test', + uuid: 'test-uuid', params: { foo: true, }, @@ -1024,6 +1060,7 @@ describe('create()', () => { group: 'default', actionRef: 'preconfigured:preconfigured', actionTypeId: 'test', + uuid: 'test-uuid-1', params: { foo: true, }, @@ -1032,6 +1069,7 @@ describe('create()', () => { group: 'default', actionRef: 'action_2', actionTypeId: 'test2', + uuid: 'test-uuid-2', params: { foo: true, }, @@ -1072,6 +1110,8 @@ describe('create()', () => { "params": Object { "foo": true, }, + "type": "default", + "uuid": "test-uuid", }, Object { "actionTypeId": "test", @@ -1080,6 +1120,8 @@ describe('create()', () => { "params": Object { "foo": true, }, + "type": "default", + "uuid": "test-uuid-1", }, Object { "actionTypeId": "test2", @@ -1088,6 +1130,8 @@ describe('create()', () => { "params": Object { "foo": true, }, + "type": "default", + "uuid": "test-uuid-2", }, ], "alertTypeId": "123", @@ -1181,265 +1225,6 @@ describe('create()', () => { expect(actionsClient.isPreconfigured).toHaveBeenCalledTimes(3); }); - test('creates a rule with some actions using system connectors', async () => { - const data = getMockData({ - actions: [ - { - group: 'default', - id: '1', - params: { - foo: true, - }, - }, - { - group: 'default', - id: 'system_action-id', - params: {}, - }, - { - group: 'default', - id: '2', - params: { - foo: true, - }, - }, - ], - }); - - actionsClient.getBulk.mockReset(); - actionsClient.getBulk.mockResolvedValue([ - { - id: '1', - actionTypeId: 'test', - config: { - from: 'me@me.com', - hasAuth: false, - host: 'hello', - port: 22, - secure: null, - service: null, - }, - isMissingSecrets: false, - name: 'email connector', - isPreconfigured: false, - isDeprecated: false, - isSystemAction: false, - }, - { - id: '2', - actionTypeId: 'test2', - config: { - from: 'me@me.com', - hasAuth: false, - host: 'hello', - port: 22, - secure: null, - service: null, - }, - isMissingSecrets: false, - name: 'another email connector', - isPreconfigured: false, - isDeprecated: false, - isSystemAction: false, - }, - { - id: 'system_action-id', - actionTypeId: 'test', - config: {}, - isMissingSecrets: false, - name: 'system action connector', - isPreconfigured: false, - isDeprecated: false, - isSystemAction: true, - }, - ]); - - actionsClient.isSystemAction.mockReset(); - actionsClient.isSystemAction.mockReturnValueOnce(false); - actionsClient.isSystemAction.mockReturnValueOnce(true); - actionsClient.isSystemAction.mockReturnValueOnce(false); - - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - id: '1', - type: 'alert', - attributes: { - executionStatus: getRuleExecutionStatusPending('2019-02-12T21:01:22.479Z'), - alertTypeId: '123', - schedule: { interval: '1m' }, - params: { - bar: true, - }, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - notifyWhen: null, - actions: [ - { - group: 'default', - actionRef: 'action_0', - actionTypeId: 'test', - params: { - foo: true, - }, - }, - { - group: 'default', - actionRef: 'system_action:system_action-id', - actionTypeId: 'test', - params: {}, - }, - { - group: 'default', - actionRef: 'action_2', - actionTypeId: 'test2', - params: { - foo: true, - }, - }, - ], - running: false, - }, - references: [ - { - name: 'action_0', - type: 'action', - id: '1', - }, - { - name: 'action_2', - type: 'action', - id: '2', - }, - ], - }); - - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - id: '1', - type: 'alert', - attributes: { - actions: [], - scheduledTaskId: 'task-123', - }, - references: [], - }); - - const result = await rulesClient.create({ data }); - - expect(result).toMatchInlineSnapshot(` - Object { - "actions": Array [ - Object { - "actionTypeId": "test", - "group": "default", - "id": "1", - "params": Object { - "foo": true, - }, - }, - Object { - "actionTypeId": "test", - "group": "default", - "id": "system_action-id", - "params": Object {}, - }, - Object { - "actionTypeId": "test2", - "group": "default", - "id": "2", - "params": Object { - "foo": true, - }, - }, - ], - "alertTypeId": "123", - "createdAt": 2019-02-12T21:01:22.479Z, - "executionStatus": Object { - "lastExecutionDate": 2019-02-12T21:01:22.000Z, - "status": "pending", - }, - "id": "1", - "notifyWhen": null, - "params": Object { - "bar": true, - }, - "running": false, - "schedule": Object { - "interval": "1m", - }, - "scheduledTaskId": "task-123", - "updatedAt": 2019-02-12T21:01:22.479Z, - } - `); - - expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledWith( - 'alert', - { - actions: [ - { - group: 'default', - actionRef: 'action_0', - actionTypeId: 'test', - params: { - foo: true, - }, - uuid: '111', - }, - { - group: 'default', - actionRef: 'system_action:system_action-id', - actionTypeId: 'test', - params: {}, - uuid: '112', - }, - { - group: 'default', - actionRef: 'action_2', - actionTypeId: 'test2', - params: { - foo: true, - }, - uuid: '113', - }, - ], - alertTypeId: '123', - apiKey: null, - apiKeyOwner: null, - apiKeyCreatedByUser: null, - consumer: 'bar', - createdAt: '2019-02-12T21:01:22.479Z', - createdBy: 'elastic', - enabled: true, - legacyId: null, - executionStatus: { - lastExecutionDate: '2019-02-12T21:01:22.479Z', - status: 'pending', - }, - monitoring: getDefaultMonitoring('2019-02-12T21:01:22.479Z'), - meta: { versionApiKeyLastmodified: kibanaVersion }, - muteAll: false, - snoozeSchedule: [], - mutedInstanceIds: [], - name: 'abc', - notifyWhen: null, - params: { bar: true }, - revision: 0, - running: false, - schedule: { interval: '1m' }, - tags: ['foo'], - throttle: null, - updatedAt: '2019-02-12T21:01:22.479Z', - updatedBy: 'elastic', - }, - { - id: 'mock-saved-object-id', - references: [ - { id: '1', name: 'action_0', type: 'action' }, - { id: '2', name: 'action_2', type: 'action' }, - ], - } - ); - expect(actionsClient.isSystemAction).toHaveBeenCalledTimes(3); - }); - test('creates a disabled alert', async () => { const data = getMockData({ enabled: false }); unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ @@ -1462,6 +1247,7 @@ describe('create()', () => { group: 'default', actionRef: 'action_0', actionTypeId: 'test', + uuid: 'test-uuid', params: { foo: true, }, @@ -1487,6 +1273,8 @@ describe('create()', () => { "params": Object { "foo": true, }, + "type": "default", + "uuid": "test-uuid", }, ], "alertTypeId": "123", @@ -1577,6 +1365,7 @@ describe('create()', () => { group: 'default', actionRef: 'action_0', actionTypeId: 'test', + uuid: 'test-uuid', params: { foo: true, }, @@ -1617,7 +1406,7 @@ describe('create()', () => { actionTypeId: 'test', group: 'default', params: { foo: true }, - uuid: '115', + uuid: '112', }, ], alertTypeId: '123', @@ -1675,6 +1464,8 @@ describe('create()', () => { "params": Object { "foo": true, }, + "type": "default", + "uuid": "test-uuid", }, ], "alertTypeId": "123", @@ -1763,6 +1554,7 @@ describe('create()', () => { group: 'default', actionRef: 'action_0', actionTypeId: 'test', + uuid: 'test-uuid', params: { foo: true, }, @@ -1804,7 +1596,7 @@ describe('create()', () => { actionTypeId: 'test', group: 'default', params: { foo: true }, - uuid: '116', + uuid: '113', }, ], alertTypeId: '123', @@ -1862,6 +1654,8 @@ describe('create()', () => { "params": Object { "foo": true, }, + "type": "default", + "uuid": "test-uuid", }, ], "alertTypeId": "123", @@ -1908,6 +1702,7 @@ describe('create()', () => { group: 'default', actionRef: 'action_0', actionTypeId: 'test', + uuid: 'test-uuid', params: { foo: true, }, @@ -1950,6 +1745,7 @@ describe('create()', () => { group: 'default', actionRef: 'action_0', actionTypeId: 'test', + uuid: 'test-uuid', params: { foo: true, }, @@ -1979,7 +1775,7 @@ describe('create()', () => { group: 'default', actionTypeId: 'test', params: { foo: true }, - uuid: '118', + uuid: '115', }, ], alertTypeId: '123', @@ -2034,6 +1830,8 @@ describe('create()', () => { "params": Object { "foo": true, }, + "type": "default", + "uuid": "test-uuid", }, ], "alertTypeId": "123", @@ -2091,6 +1889,7 @@ describe('create()', () => { group: 'default', actionRef: 'action_0', actionTypeId: 'test', + uuid: 'test-uuid', params: { foo: true, }, @@ -2120,7 +1919,7 @@ describe('create()', () => { group: 'default', actionTypeId: 'test', params: { foo: true }, - uuid: '119', + uuid: '116', }, ], legacyId: null, @@ -2175,6 +1974,8 @@ describe('create()', () => { "params": Object { "foo": true, }, + "type": "default", + "uuid": "test-uuid", }, ], "alertTypeId": "123", @@ -2232,6 +2033,7 @@ describe('create()', () => { group: 'default', actionRef: 'action_0', actionTypeId: 'test', + uuid: 'test-uuid', params: { foo: true, }, @@ -2261,7 +2063,7 @@ describe('create()', () => { group: 'default', actionTypeId: 'test', params: { foo: true }, - uuid: '120', + uuid: '117', }, ], legacyId: null, @@ -2316,6 +2118,8 @@ describe('create()', () => { "params": Object { "foo": true, }, + "type": "default", + "uuid": "test-uuid", }, ], "alertTypeId": "123", @@ -2381,6 +2185,7 @@ describe('create()', () => { group: 'default', actionRef: 'action_0', actionTypeId: 'test', + uuid: 'test-uuid', params: { foo: true, }, @@ -2429,7 +2234,7 @@ describe('create()', () => { }, actionRef: 'action_0', actionTypeId: 'test', - uuid: '121', + uuid: '118', }, ], apiKeyOwner: null, @@ -2498,6 +2303,8 @@ describe('create()', () => { "params": Object { "foo": true, }, + "type": "default", + "uuid": "test-uuid", }, ], "alertTypeId": "123", @@ -2615,6 +2422,7 @@ describe('create()', () => { group: 'default', actionRef: 'action_0', actionTypeId: 'test', + uuid: 'test-uuid', params: { foo: true, }, @@ -2664,6 +2472,7 @@ describe('create()', () => { group: 'default', actionRef: 'action_0', actionTypeId: 'test', + uuid: 'test-uuid', params: { foo: true, }, @@ -2711,6 +2520,7 @@ describe('create()', () => { group: 'default', actionRef: 'action_0', actionTypeId: 'test', + uuid: 'test-uuid', params: { foo: true, }, @@ -2769,6 +2579,7 @@ describe('create()', () => { group: 'default', actionRef: 'action_0', actionTypeId: 'test', + uuid: 'test-uuid', params: { foo: true, }, @@ -2810,7 +2621,7 @@ describe('create()', () => { group: 'default', actionTypeId: 'test', params: { foo: true }, - uuid: '129', + uuid: '126', }, ], alertTypeId: '123', @@ -2872,6 +2683,7 @@ describe('create()', () => { group: 'default', actionRef: 'action_0', actionTypeId: 'test', + uuid: 'test-uuid', params: { foo: true, }, @@ -2915,7 +2727,7 @@ describe('create()', () => { group: 'default', actionTypeId: 'test', params: { foo: true }, - uuid: '130', + uuid: '127', }, ], legacyId: null, @@ -3053,6 +2865,7 @@ describe('create()', () => { group: 'default', actionRef: 'action_0', actionTypeId: 'test', + uuid: 'test-uuid', params: { foo: true, }, @@ -3120,6 +2933,7 @@ describe('create()', () => { ...rulesClientParams, minimumScheduleInterval: { value: '1m', enforce: true }, }); + ruleTypeRegistry.get.mockImplementation(() => ({ id: '123', name: 'Test', @@ -3158,6 +2972,7 @@ describe('create()', () => { notifyWhen: 'onActionGroupChange', throttle: null, }, + type: RuleActionTypes.DEFAULT, }, { group: 'group2', @@ -3170,6 +2985,7 @@ describe('create()', () => { notifyWhen: 'onActionGroupChange', throttle: null, }, + type: RuleActionTypes.DEFAULT, }, ], }); @@ -3193,6 +3009,7 @@ describe('create()', () => { notifyWhen: 'onActionGroupChange', throttle: null, }, + type: RuleActionTypes.DEFAULT, }, { group: 'group2', @@ -3200,6 +3017,7 @@ describe('create()', () => { params: { foo: true, }, + type: RuleActionTypes.DEFAULT, }, ], }); @@ -3246,6 +3064,7 @@ describe('create()', () => { params: { foo: true, }, + type: RuleActionTypes.DEFAULT, }, ], }); @@ -3300,6 +3119,7 @@ describe('create()', () => { notifyWhen: 'onActionGroupChange', throttle: null, }, + type: RuleActionTypes.DEFAULT, }, { group: 'group2', @@ -3307,6 +3127,7 @@ describe('create()', () => { params: { foo: true, }, + type: RuleActionTypes.DEFAULT, }, ], }); @@ -3363,6 +3184,7 @@ describe('create()', () => { notifyWhen: 'onThrottleInterval', throttle: '1h', }, + type: RuleActionTypes.DEFAULT, }, { group: 'group2', @@ -3375,6 +3197,7 @@ describe('create()', () => { notifyWhen: 'onThrottleInterval', throttle: '3m', }, + type: RuleActionTypes.DEFAULT, }, { group: 'group3', @@ -3387,6 +3210,7 @@ describe('create()', () => { notifyWhen: 'onThrottleInterval', throttle: '240m', }, + type: RuleActionTypes.DEFAULT, }, ], }); @@ -3443,6 +3267,7 @@ describe('create()', () => { notifyWhen: 'onThrottleInterval', throttle: '1h', }, + type: RuleActionTypes.DEFAULT, }, { group: 'group2', @@ -3455,6 +3280,7 @@ describe('create()', () => { notifyWhen: 'onThrottleInterval', throttle: '3m', }, + type: RuleActionTypes.DEFAULT, }, { group: 'group3', @@ -3462,6 +3288,7 @@ describe('create()', () => { params: { foo: true, }, + type: RuleActionTypes.DEFAULT, }, ], }); @@ -3482,6 +3309,7 @@ describe('create()', () => { params: { foo: true, }, + type: RuleActionTypes.DEFAULT, }, { group: 'default', @@ -3489,6 +3317,7 @@ describe('create()', () => { params: { foo: true, }, + type: RuleActionTypes.DEFAULT, }, { group: 'default', @@ -3496,6 +3325,7 @@ describe('create()', () => { params: { foo: true, }, + type: RuleActionTypes.DEFAULT, }, ], }); @@ -3530,6 +3360,7 @@ describe('create()', () => { group: 'default', actionRef: 'action_0', actionTypeId: '.slack', + uuid: 'test-uuid', params: { foo: true, }, @@ -3574,6 +3405,8 @@ describe('create()', () => { "params": Object { "foo": true, }, + "type": "default", + "uuid": "test-uuid", }, ], "alertTypeId": "123", @@ -3647,11 +3480,12 @@ describe('create()', () => { throttle: '10h', }, alertsFilter: {}, + type: RuleActionTypes.DEFAULT, }, ], }); await expect(rulesClient.create({ data })).rejects.toThrowErrorMatchingInlineSnapshot( - `"Failed to validate actions due to the following error: Action's alertsFilter must have either \\"query\\" or \\"timeframe\\" : 152"` + `"Failed to validate actions due to the following error: Action's alertsFilter must have either \\"query\\" or \\"timeframe\\" : 149"` ); expect(unsecuredSavedObjectsClient.create).not.toHaveBeenCalled(); expect(taskManager.schedule).not.toHaveBeenCalled(); @@ -3701,11 +3535,12 @@ describe('create()', () => { alertsFilter: { query: { kql: 'test:1', filters: [] }, }, + type: RuleActionTypes.DEFAULT, }, ], }); await expect(rulesClient.create({ data })).rejects.toThrowErrorMatchingInlineSnapshot( - `"Failed to validate actions due to the following error: This ruleType (Test) can't have an action with Alerts Filter. Actions: [153]"` + `"Failed to validate actions due to the following error: This ruleType (Test) can't have an action with Alerts Filter. Actions: [150]"` ); expect(unsecuredSavedObjectsClient.create).not.toHaveBeenCalled(); expect(taskManager.schedule).not.toHaveBeenCalled(); @@ -3734,6 +3569,7 @@ describe('create()', () => { group: 'default', actionRef: 'action_0', actionTypeId: 'test', + uuid: 'test-uuid', params: { foo: true, }, @@ -3776,7 +3612,7 @@ describe('create()', () => { group: 'default', actionTypeId: 'test', params: { foo: true }, - uuid: '154', + uuid: '151', }, ], alertTypeId: '123', @@ -3843,4 +3679,381 @@ describe('create()', () => { expect.any(Object) ); }); + + describe('actions', () => { + const connectorAdapter: ConnectorAdapter = { + connectorTypeId: '.test', + ruleActionParamsSchema: schema.object({ foo: schema.string() }), + buildActionParams: jest.fn(), + }; + + connectorAdapterRegistry.register(connectorAdapter); + + beforeEach(() => { + actionsClient.getBulk.mockReset(); + actionsClient.getBulk.mockResolvedValue([ + { + id: '1', + actionTypeId: 'test', + config: { + from: 'me@me.com', + hasAuth: false, + host: 'hello', + port: 22, + secure: null, + service: null, + }, + isMissingSecrets: false, + name: 'email connector', + isPreconfigured: false, + isDeprecated: false, + isSystemAction: false, + }, + { + id: 'system_action-id', + actionTypeId: '.test', + config: {}, + isMissingSecrets: false, + name: 'system action connector', + isPreconfigured: false, + isDeprecated: false, + isSystemAction: true, + }, + ]); + + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + executionStatus: getRuleExecutionStatusPending('2019-02-12T21:01:22.479Z'), + alertTypeId: '123', + schedule: { interval: '1m' }, + params: { + bar: true, + }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + notifyWhen: null, + actions: [ + { + group: 'default', + actionRef: 'action_0', + actionTypeId: 'test', + uuid: 'test-uuid', + params: { + foo: true, + }, + }, + { + group: 'default', + actionRef: 'system_action:system_action-id', + actionTypeId: 'test', + uuid: 'test-uuid-1', + params: { foo: 'test' }, + }, + ], + running: false, + }, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + ], + }); + }); + + test('create a rule with system actions and default actions', async () => { + const data = getMockData({ + actions: [ + { + group: 'default', + id: '1', + params: { + foo: true, + }, + type: RuleActionTypes.DEFAULT, + }, + { + id: 'system_action-id', + params: { + foo: 'test', + }, + type: RuleActionTypes.SYSTEM, + }, + ], + }); + + const result = await rulesClient.create({ data }); + + expect(result).toMatchInlineSnapshot(` + Object { + "actions": Array [ + Object { + "actionTypeId": "test", + "group": "default", + "id": "1", + "params": Object { + "foo": true, + }, + "type": "default", + "uuid": "test-uuid", + }, + Object { + "actionTypeId": "test", + "id": "system_action-id", + "params": Object { + "foo": "test", + }, + "type": "system", + "uuid": "test-uuid-1", + }, + ], + "alertTypeId": "123", + "createdAt": 2019-02-12T21:01:22.479Z, + "executionStatus": Object { + "lastExecutionDate": 2019-02-12T21:01:22.000Z, + "status": "pending", + }, + "id": "1", + "notifyWhen": null, + "params": Object { + "bar": true, + }, + "running": false, + "schedule": Object { + "interval": "1m", + }, + "scheduledTaskId": "task-123", + "updatedAt": 2019-02-12T21:01:22.479Z, + } + `); + + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledWith( + 'alert', + { + actions: [ + { + group: 'default', + actionRef: 'action_0', + actionTypeId: 'test', + params: { + foo: true, + }, + uuid: '153', + }, + { + actionRef: 'system_action:system_action-id', + actionTypeId: '.test', + params: { foo: 'test' }, + uuid: '154', + }, + ], + alertTypeId: '123', + apiKey: null, + apiKeyOwner: null, + apiKeyCreatedByUser: null, + consumer: 'bar', + createdAt: '2019-02-12T21:01:22.479Z', + createdBy: 'elastic', + enabled: true, + legacyId: null, + executionStatus: { + lastExecutionDate: '2019-02-12T21:01:22.479Z', + status: 'pending', + }, + monitoring: getDefaultMonitoring('2019-02-12T21:01:22.479Z'), + meta: { versionApiKeyLastmodified: kibanaVersion }, + muteAll: false, + snoozeSchedule: [], + mutedInstanceIds: [], + name: 'abc', + notifyWhen: null, + params: { bar: true }, + revision: 0, + running: false, + schedule: { interval: '1m' }, + tags: ['foo'], + throttle: null, + updatedAt: '2019-02-12T21:01:22.479Z', + updatedBy: 'elastic', + }, + { + id: 'mock-saved-object-id', + references: [{ id: '1', name: 'action_0', type: 'action' }], + } + ); + }); + + test('should construct the refs correctly and not persist the type of the action', async () => { + const data = getMockData({ + actions: [ + { + group: 'default', + id: '1', + params: { + foo: true, + }, + type: RuleActionTypes.DEFAULT, + }, + { + id: 'system_action-id', + params: { + foo: 'test', + }, + type: RuleActionTypes.SYSTEM, + }, + ], + }); + + await rulesClient.create({ data }); + + const rule = unsecuredSavedObjectsClient.create.mock.calls[0][1] as RuleDomain; + + expect(rule.actions).toEqual([ + { + group: 'default', + actionRef: 'action_0', + actionTypeId: 'test', + params: { + foo: true, + }, + uuid: '155', + }, + { + actionRef: 'system_action:system_action-id', + actionTypeId: '.test', + params: { foo: 'test' }, + uuid: '156', + }, + ]); + }); + + test('should add the actions type to the response correctly', async () => { + const data = getMockData({ + actions: [ + { + group: 'default', + id: '1', + params: { + foo: true, + }, + type: RuleActionTypes.DEFAULT, + }, + { + id: 'system_action-id', + params: { + foo: 'test', + }, + type: RuleActionTypes.SYSTEM, + }, + ], + }); + + const result = await rulesClient.create({ data }); + + expect(result.actions).toMatchInlineSnapshot(` + Array [ + Object { + "actionTypeId": "test", + "group": "default", + "id": "1", + "params": Object { + "foo": true, + }, + "type": "default", + "uuid": "test-uuid", + }, + Object { + "actionTypeId": "test", + "id": "system_action-id", + "params": Object { + "foo": "test", + }, + "type": "system", + "uuid": "test-uuid-1", + }, + ] + `); + }); + + test('should throw an error if the system action does not exist', async () => { + const action: RuleSystemAction = { + id: 'fake-system-action', + uuid: '123', + params: {}, + actionTypeId: '.test', + type: RuleActionTypes.SYSTEM, + }; + + const data = getMockData({ actions: [action] }); + await expect(() => rulesClient.create({ data })).rejects.toMatchInlineSnapshot( + `[Error: Action fake-system-action is not a system action]` + ); + + expect(actionsClient.getBulk).toBeCalledWith({ + ids: ['fake-system-action'], + throwIfSystemAction: false, + }); + }); + + test('should throw an error if the system action contains the frequency', async () => { + const action = { + id: 'system_action-id', + uuid: '123', + params: {}, + actionTypeId: '.test', + frequency: { + summary: false, + notifyWhen: 'onActionGroupChange', + throttle: null, + }, + type: RuleActionTypes.SYSTEM, + }; + + const data = getMockData({ actions: [action] }); + await expect(() => rulesClient.create({ data })).rejects.toMatchInlineSnapshot(` + [Error: Error validating create data - [actions.0]: types that failed validation: + - [actions.0.0.group]: expected value of type [string] but got [undefined] + - [actions.0.1.frequency]: definition for this key is missing] + `); + }); + + test('should throw an error if the system action contains the alertsFilter', async () => { + const action = { + id: 'system_action-id', + uuid: '123', + params: {}, + actionTypeId: '.test', + alertsFilter: { + query: { kql: 'test:1', filters: [] }, + }, + type: RuleActionTypes.SYSTEM, + }; + + const data = getMockData({ actions: [action] }); + await expect(() => rulesClient.create({ data })).rejects.toMatchInlineSnapshot(` + [Error: Error validating create data - [actions.0]: types that failed validation: + - [actions.0.0.group]: expected value of type [string] but got [undefined] + - [actions.0.1.alertsFilter]: definition for this key is missing] + `); + }); + + test('should throw an error if the default action does not contain the group', async () => { + const action = { + id: 'action-id-1', + params: {}, + actionTypeId: '.test', + type: RuleActionTypes.DEFAULT, + }; + + const data = getMockData({ actions: [action] }); + await expect(() => rulesClient.create({ data })).rejects.toMatchInlineSnapshot(` + [Error: Error validating create data - [actions.0]: types that failed validation: + - [actions.0.0.group]: expected value of type [string] but got [undefined] + - [actions.0.1.type]: expected value to equal [system]] + `); + }); + }); }); diff --git a/x-pack/plugins/alerting/server/application/rule/methods/create/create_rule.ts b/x-pack/plugins/alerting/server/application/rule/methods/create/create_rule.ts index 616a16a8315ed..2a1f75d434e19 100644 --- a/x-pack/plugins/alerting/server/application/rule/methods/create/create_rule.ts +++ b/x-pack/plugins/alerting/server/application/rule/methods/create/create_rule.ts @@ -8,6 +8,7 @@ import Semver from 'semver'; import Boom from '@hapi/boom'; import { SavedObject, SavedObjectsUtils } from '@kbn/core/server'; import { withSpan } from '@kbn/apm-utils'; +import { validateSystemActions } from '../../../../lib/validate_system_actions'; import { parseDuration } from '../../../../../common/parse_duration'; import { WriteOperations, AlertingAuthorizationEntity } from '../../../../authorization'; import { @@ -25,7 +26,7 @@ import { generateAPIKeyName, apiKeyAsRuleDomainProperties } from '../../../../ru import { ruleAuditEvent, RuleAuditAction } from '../../../../rules_client/common/audit_events'; import { RulesClientContext } from '../../../../rules_client/types'; import { RuleDomain, RuleParams } from '../../types'; -import { SanitizedRule } from '../../../../types'; +import { RuleActionTypes, RuleSystemAction, SanitizedRule } from '../../../../types'; import { transformRuleAttributesToRuleDomain, transformRuleDomainToRuleAttributes, @@ -54,8 +55,16 @@ export async function createRule( // TODO (http-versioning): This should be of type Rule, change this when all rule types are fixed ): Promise> { const { data: initialData, options, allowMissingConnectorSecrets } = createParams; + const actionsClient = await context.getActionsClient(); - const data = { ...initialData, actions: addGeneratedActionValues(initialData.actions) }; + const systemActions = initialData.actions.filter( + (action): action is RuleSystemAction => action.type === RuleActionTypes.SYSTEM + ); + + const data = { + ...initialData, + actions: addGeneratedActionValues(initialData.actions), + }; const id = options?.id || SavedObjectsUtils.generateId(); @@ -71,6 +80,12 @@ export async function createRule( throw Boom.badRequest(`Error validating create data - ${error.message}`); } + await validateSystemActions({ + actionsClient, + connectorAdapterRegistry: context.connectorAdapterRegistry, + systemActions, + }); + try { await withSpan({ name: 'authorization.ensureAuthorized', type: 'rules' }, () => context.authorization.ensureAuthorized({ @@ -199,7 +214,8 @@ export async function createRule( logger: context.logger, ruleType: context.ruleTypeRegistry.get(createdRuleSavedObject.attributes.alertTypeId), references, - } + }, + (connectorId: string) => actionsClient.isSystemAction(connectorId) ); // Try to validate created rule, but don't throw. diff --git a/x-pack/plugins/alerting/server/application/rule/methods/create/schemas/create_rule_data_schema.ts b/x-pack/plugins/alerting/server/application/rule/methods/create/schemas/create_rule_data_schema.ts index ce51e88c5ce73..4cdca61fec89b 100644 --- a/x-pack/plugins/alerting/server/application/rule/methods/create/schemas/create_rule_data_schema.ts +++ b/x-pack/plugins/alerting/server/application/rule/methods/create/schemas/create_rule_data_schema.ts @@ -6,9 +6,35 @@ */ import { schema } from '@kbn/config-schema'; +import { RuleActionTypes } from '../../../../../../common'; import { validateDuration } from '../../../validation'; import { notifyWhenSchema, actionAlertsFilterSchema } from '../../../schemas'; +const defaultActionSchema = schema.object({ + group: schema.string(), + id: schema.string(), + actionTypeId: schema.maybe(schema.string()), + params: schema.recordOf(schema.string(), schema.maybe(schema.any()), { defaultValue: {} }), + frequency: schema.maybe( + schema.object({ + summary: schema.boolean(), + notifyWhen: notifyWhenSchema, + throttle: schema.nullable(schema.string({ validate: validateDuration })), + }) + ), + uuid: schema.maybe(schema.string()), + alertsFilter: schema.maybe(actionAlertsFilterSchema), + type: schema.literal(RuleActionTypes.DEFAULT), +}); + +export const systemActionSchema = schema.object({ + id: schema.string(), + actionTypeId: schema.maybe(schema.string()), + params: schema.recordOf(schema.string(), schema.maybe(schema.any()), { defaultValue: {} }), + uuid: schema.maybe(schema.string()), + type: schema.literal(RuleActionTypes.SYSTEM), +}); + export const createRuleDataSchema = schema.object({ name: schema.string(), alertTypeId: schema.string(), @@ -20,23 +46,8 @@ export const createRuleDataSchema = schema.object({ schedule: schema.object({ interval: schema.string({ validate: validateDuration }), }), - actions: schema.arrayOf( - schema.object({ - group: schema.string(), - id: schema.string(), - actionTypeId: schema.maybe(schema.string()), - params: schema.recordOf(schema.string(), schema.maybe(schema.any()), { defaultValue: {} }), - frequency: schema.maybe( - schema.object({ - summary: schema.boolean(), - notifyWhen: notifyWhenSchema, - throttle: schema.nullable(schema.string({ validate: validateDuration })), - }) - ), - uuid: schema.maybe(schema.string()), - alertsFilter: schema.maybe(actionAlertsFilterSchema), - }), - { defaultValue: [] } - ), + actions: schema.arrayOf(schema.oneOf([defaultActionSchema, systemActionSchema]), { + defaultValue: [], + }), notifyWhen: schema.maybe(schema.nullable(notifyWhenSchema)), }); diff --git a/x-pack/plugins/alerting/server/application/rule/methods/get_schedule_frequency/get_schedule_frequency.test.ts b/x-pack/plugins/alerting/server/application/rule/methods/get_schedule_frequency/get_schedule_frequency.test.ts index 99e3b1a4a7761..da7d754ffdf7b 100644 --- a/x-pack/plugins/alerting/server/application/rule/methods/get_schedule_frequency/get_schedule_frequency.test.ts +++ b/x-pack/plugins/alerting/server/application/rule/methods/get_schedule_frequency/get_schedule_frequency.test.ts @@ -55,6 +55,7 @@ const rulesClientParams: jest.Mocked = { isAuthenticationTypeAPIKey: jest.fn(), getAuthenticationAPIKey: jest.fn(), connectorAdapterRegistry: new ConnectorAdapterRegistry(), + isSystemAction: jest.fn(), }; const getMockAggregationResult = ( diff --git a/x-pack/plugins/alerting/server/application/rule/schemas/action_schemas.ts b/x-pack/plugins/alerting/server/application/rule/schemas/action_schemas.ts index f123466eca1ab..38f04823870c7 100644 --- a/x-pack/plugins/alerting/server/application/rule/schemas/action_schemas.ts +++ b/x-pack/plugins/alerting/server/application/rule/schemas/action_schemas.ts @@ -6,6 +6,7 @@ */ import { schema } from '@kbn/config-schema'; +import { RuleActionTypes } from '../../../../common'; import { notifyWhenSchema } from './notify_when_schema'; export const actionParamsSchema = schema.recordOf(schema.string(), schema.maybe(schema.any())); @@ -57,7 +58,7 @@ const actionFrequencySchema = schema.object({ /** * Unsanitized (domain) action schema, used by internal rules clients */ -export const actionDomainSchema = schema.object({ +export const defaultActionDomainSchema = schema.object({ uuid: schema.maybe(schema.string()), group: schema.string(), id: schema.string(), @@ -65,8 +66,22 @@ export const actionDomainSchema = schema.object({ params: actionParamsSchema, frequency: schema.maybe(actionFrequencySchema), alertsFilter: schema.maybe(actionDomainAlertsFilterSchema), + type: schema.literal(RuleActionTypes.DEFAULT), }); +export const systemActionDomainSchema = schema.object({ + id: schema.string(), + actionTypeId: schema.string(), + params: actionParamsSchema, + uuid: schema.maybe(schema.string()), + type: schema.literal(RuleActionTypes.SYSTEM), +}); + +export const actionDomainSchema = schema.oneOf([ + defaultActionDomainSchema, + systemActionDomainSchema, +]); + /** * Sanitized (non-domain) action schema, returned by rules clients for other solutions */ @@ -81,7 +96,7 @@ export const actionAlertsFilterSchema = schema.object({ timeframe: schema.maybe(actionAlertsFilterTimeFrameSchema), }); -export const actionSchema = schema.object({ +export const defaultActionSchema = schema.object({ uuid: schema.maybe(schema.string()), group: schema.string(), id: schema.string(), @@ -89,4 +104,15 @@ export const actionSchema = schema.object({ params: actionParamsSchema, frequency: schema.maybe(actionFrequencySchema), alertsFilter: schema.maybe(actionAlertsFilterSchema), + type: schema.literal(RuleActionTypes.DEFAULT), }); + +export const systemActionSchema = schema.object({ + id: schema.string(), + actionTypeId: schema.string(), + params: actionParamsSchema, + uuid: schema.maybe(schema.string()), + type: schema.literal(RuleActionTypes.SYSTEM), +}); + +export const actionSchema = schema.oneOf([defaultActionSchema, systemActionSchema]); diff --git a/x-pack/plugins/alerting/server/application/rule/transforms/transform_raw_actions_to_domain_actions.test.ts b/x-pack/plugins/alerting/server/application/rule/transforms/transform_raw_actions_to_domain_actions.test.ts new file mode 100644 index 0000000000000..eb9f21979e4fb --- /dev/null +++ b/x-pack/plugins/alerting/server/application/rule/transforms/transform_raw_actions_to_domain_actions.test.ts @@ -0,0 +1,103 @@ +/* + * 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 { RecoveredActionGroup } from '../../../../common'; +import { UntypedNormalizedRuleType } from '../../../rule_type_registry'; +import { RuleActionAttributes } from '../../../data/rule/types'; +import { transformRawActionsToDomainActions } from './transform_raw_actions_to_domain_actions'; + +const ruleType: jest.Mocked = { + id: 'test.rule-type', + name: 'My test rule', + actionGroups: [{ id: 'default', name: 'Default' }, RecoveredActionGroup], + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + recoveryActionGroup: RecoveredActionGroup, + executor: jest.fn(), + producer: 'alerts', + cancelAlertsOnRuleTimeout: true, + ruleTaskTimeout: '5m', + autoRecoverAlerts: true, + doesSetRecoveryContext: true, + validate: { + params: { validate: (params) => params }, + }, + alerts: { + context: 'test', + mappings: { fieldMap: { field: { type: 'keyword', required: false } } }, + shouldWrite: true, + }, +}; + +const defaultAction: RuleActionAttributes = { + group: 'default', + uuid: '1', + actionRef: 'default-action-ref', + actionTypeId: '.test', + params: {}, + frequency: { + summary: false, + notifyWhen: 'onThrottleInterval', + throttle: '1m', + }, + alertsFilter: { query: { kql: 'test:1', dsl: '{}', filters: [] } }, +}; + +const systemAction: RuleActionAttributes = { + actionRef: 'system_action:my-system-action-id', + uuid: '123', + actionTypeId: '.test-system-action', + params: {}, +}; + +const isSystemAction = (id: string) => id === 'my-system-action-id'; + +describe('transformRawActionsToDomainActions', () => { + it('transforms the actions correctly', () => { + const res = transformRawActionsToDomainActions({ + actions: [defaultAction, systemAction], + ruleId: 'test-rule', + references: [ + { name: 'system_action:my-system-action-id', id: 'my-system-action-id', type: 'action' }, + { name: 'default-action-ref', id: 'default-action-id', type: 'action' }, + ], + isSystemAction, + }); + + expect(res).toMatchInlineSnapshot(` + Array [ + Object { + "actionTypeId": ".test", + "alertsFilter": Object { + "query": Object { + "filters": Array [], + "kql": "test:1", + }, + }, + "frequency": Object { + "notifyWhen": "onThrottleInterval", + "summary": false, + "throttle": "1m", + }, + "group": "default", + "id": "default-action-id", + "params": Object {}, + "type": "default", + "uuid": "1", + }, + Object { + "actionTypeId": ".test-system-action", + "id": "my-system-action-id", + "params": Object {}, + "type": "system", + "uuid": "123", + }, + ] + `); + }); +}); diff --git a/x-pack/plugins/alerting/server/application/rule/transforms/transform_raw_actions_to_domain_actions.ts b/x-pack/plugins/alerting/server/application/rule/transforms/transform_raw_actions_to_domain_actions.ts new file mode 100644 index 0000000000000..d6c7533befdf1 --- /dev/null +++ b/x-pack/plugins/alerting/server/application/rule/transforms/transform_raw_actions_to_domain_actions.ts @@ -0,0 +1,64 @@ +/* + * 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 { omit } from 'lodash'; +import { SavedObjectReference } from '@kbn/core/server'; +import { injectReferencesIntoActions } from '../../../rules_client/common'; +import { RuleAttributes } from '../../../data/rule/types'; +import { RawRule, RuleActionTypes } from '../../../types'; +import { RuleDomain } from '../types'; + +interface Args { + ruleId: string; + actions: RuleAttributes['actions'] | RawRule['actions']; + isSystemAction: (connectorId: string) => boolean; + omitGeneratedValues?: boolean; + references?: SavedObjectReference[]; +} + +export const transformRawActionsToDomainActions = ({ + actions, + ruleId, + references, + omitGeneratedValues = true, + isSystemAction, +}: Args) => { + const actionsWithInjectedRefs = actions + ? injectReferencesIntoActions(ruleId, actions, references || []) + : []; + + const ruleDomainActions: RuleDomain['actions'] = actionsWithInjectedRefs.map((action) => { + if (isSystemAction(action.id)) { + return { + id: action.id, + params: action.params, + actionTypeId: action.actionTypeId, + uuid: action.uuid, + type: RuleActionTypes.SYSTEM, + }; + } + + const defaultAction = { + group: action.group ?? 'default', + id: action.id, + params: action.params, + actionTypeId: action.actionTypeId, + uuid: action.uuid, + ...(action.frequency ? { frequency: action.frequency } : {}), + ...(action.alertsFilter ? { alertsFilter: action.alertsFilter } : {}), + type: RuleActionTypes.DEFAULT, + }; + + if (omitGeneratedValues) { + return omit(defaultAction, 'alertsFilter.query.dsl'); + } + + return defaultAction; + }); + + return ruleDomainActions; +}; diff --git a/x-pack/plugins/alerting/server/application/rule/transforms/transform_rule_attributes_to_rule_domain.test.ts b/x-pack/plugins/alerting/server/application/rule/transforms/transform_rule_attributes_to_rule_domain.test.ts new file mode 100644 index 0000000000000..a95ff00f45bd3 --- /dev/null +++ b/x-pack/plugins/alerting/server/application/rule/transforms/transform_rule_attributes_to_rule_domain.test.ts @@ -0,0 +1,138 @@ +/* + * 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 { RecoveredActionGroup } from '../../../../common'; +import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; +import { transformRuleAttributesToRuleDomain } from './transform_rule_attributes_to_rule_domain'; +import { UntypedNormalizedRuleType } from '../../../rule_type_registry'; +import { RuleActionAttributes } from '../../../data/rule/types'; + +const ruleType: jest.Mocked = { + id: 'test.rule-type', + name: 'My test rule', + actionGroups: [{ id: 'default', name: 'Default' }, RecoveredActionGroup], + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + recoveryActionGroup: RecoveredActionGroup, + executor: jest.fn(), + producer: 'alerts', + cancelAlertsOnRuleTimeout: true, + ruleTaskTimeout: '5m', + autoRecoverAlerts: true, + doesSetRecoveryContext: true, + validate: { + params: { validate: (params) => params }, + }, + alerts: { + context: 'test', + mappings: { fieldMap: { field: { type: 'keyword', required: false } } }, + shouldWrite: true, + }, +}; + +const defaultAction: RuleActionAttributes = { + group: 'default', + uuid: '1', + actionRef: 'default-action-ref', + actionTypeId: '.test', + params: {}, + frequency: { + summary: false, + notifyWhen: 'onThrottleInterval', + throttle: '1m', + }, + alertsFilter: { query: { kql: 'test:1', dsl: '{}', filters: [] } }, +}; + +const systemAction: RuleActionAttributes = { + actionRef: 'system_action:my-system-action-id', + uuid: '123', + actionTypeId: '.test-system-action', + params: {}, +}; + +const isSystemAction = (id: string) => id === 'my-system-action-id'; + +describe('transformRuleAttributesToRuleDomain', () => { + const MOCK_API_KEY = Buffer.from('123:abc').toString('base64'); + const logger = loggingSystemMock.create().get(); + const references = [{ name: 'default-action-ref', type: 'action', id: 'default-action-id' }]; + + const rule = { + enabled: false, + tags: ['foo'], + createdBy: 'user', + createdAt: '2019-02-12T21:01:22.479Z', + updatedAt: '2019-02-12T21:01:22.479Z', + legacyId: null, + muteAll: false, + mutedInstanceIds: [], + snoozeSchedule: [], + alertTypeId: 'myType', + schedule: { interval: '1m' }, + consumer: 'myApp', + scheduledTaskId: 'task-123', + executionStatus: { + lastExecutionDate: '2019-02-12T21:01:22.479Z', + status: 'pending' as const, + }, + params: {}, + throttle: null, + notifyWhen: null, + actions: [defaultAction, systemAction], + name: 'my rule name', + revision: 0, + updatedBy: 'user', + apiKey: MOCK_API_KEY, + apiKeyOwner: 'user', + }; + + it('transforms the actions correctly', () => { + const res = transformRuleAttributesToRuleDomain( + rule, + { + id: '1', + logger, + ruleType, + references, + }, + isSystemAction + ); + + expect(res.actions).toMatchInlineSnapshot(` + Array [ + Object { + "actionTypeId": ".test", + "alertsFilter": Object { + "query": Object { + "filters": Array [], + "kql": "test:1", + }, + }, + "frequency": Object { + "notifyWhen": "onThrottleInterval", + "summary": false, + "throttle": "1m", + }, + "group": "default", + "id": "default-action-id", + "params": Object {}, + "type": "default", + "uuid": "1", + }, + Object { + "actionTypeId": ".test-system-action", + "id": "my-system-action-id", + "params": Object {}, + "type": "system", + "uuid": "123", + }, + ] + `); + }); +}); diff --git a/x-pack/plugins/alerting/server/application/rule/transforms/transform_rule_attributes_to_rule_domain.ts b/x-pack/plugins/alerting/server/application/rule/transforms/transform_rule_attributes_to_rule_domain.ts index 26831b9dff81c..a90d1c900c644 100644 --- a/x-pack/plugins/alerting/server/application/rule/transforms/transform_rule_attributes_to_rule_domain.ts +++ b/x-pack/plugins/alerting/server/application/rule/transforms/transform_rule_attributes_to_rule_domain.ts @@ -4,20 +4,18 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { omit, isEmpty } from 'lodash'; +import { isEmpty } from 'lodash'; import { Logger } from '@kbn/core/server'; import { SavedObjectReference } from '@kbn/core/server'; import { ruleExecutionStatusValues } from '../constants'; import { getRuleSnoozeEndTime } from '../../../lib'; import { RuleDomain, Monitoring, RuleParams } from '../types'; import { RuleAttributes } from '../../../data/rule/types'; -import { RawRule, PartialRule } from '../../../types'; +import { PartialRule } from '../../../types'; import { UntypedNormalizedRuleType } from '../../../rule_type_registry'; -import { - injectReferencesIntoActions, - injectReferencesIntoParams, -} from '../../../rules_client/common'; +import { injectReferencesIntoParams } from '../../../rules_client/common'; import { getActiveScheduledSnoozes } from '../../../lib/is_rule_snoozed'; +import { transformRawActionsToDomainActions } from './transform_raw_actions_to_domain_actions'; const INITIAL_LAST_RUN_METRICS = { duration: 0, @@ -120,7 +118,8 @@ interface TransformEsToRuleParams { export const transformRuleAttributesToRuleDomain = ( esRule: RuleAttributes, - transformParams: TransformEsToRuleParams + transformParams: TransformEsToRuleParams, + isSystemAction: (connectorId: string) => boolean ): RuleDomain => { const { scheduledTaskId, executionStatus, monitoring, snoozeSchedule, lastRun } = esRule; @@ -141,6 +140,7 @@ export const transformRuleAttributesToRuleDomain = omit(ruleAction, 'alertsFilter.query.dsl')); - } + const ruleDomainActions: RuleDomain['actions'] = transformRawActionsToDomainActions({ + ruleId: id, + actions: esRule.actions, + references, + isSystemAction, + omitGeneratedValues, + }); const params = injectReferencesIntoParams( id, @@ -177,7 +177,7 @@ export const transformRuleAttributesToRuleDomain = { + const firstConnectorAdapter: ConnectorAdapter = { + connectorTypeId: '.test', + ruleActionParamsSchema: schema.object({ foo: schema.string() }), + buildActionParams: jest.fn(), + }; + + const secondConnectorAdapter: ConnectorAdapter = { + connectorTypeId: '.test-2', + ruleActionParamsSchema: schema.object({ bar: schema.string() }), + buildActionParams: jest.fn(), + }; + + let registry: ConnectorAdapterRegistry; + + beforeEach(() => { + registry = new ConnectorAdapterRegistry(); + }); + + describe('validateConnectorAdapterActionParams', () => { + it('should validate correctly invalid params', () => { + registry.register(firstConnectorAdapter); + + expect(() => + validateConnectorAdapterActionParams({ + connectorAdapterRegistry: registry, + connectorTypeId: firstConnectorAdapter.connectorTypeId, + params: { foo: 5 }, + }) + ).toThrowErrorMatchingInlineSnapshot( + `"Invalid system action params. System action type: .test - [foo]: expected value of type [string] but got [number]"` + ); + }); + + it('should not throw if the connectorTypeId is not defined', () => { + registry.register(firstConnectorAdapter); + + expect(() => + validateConnectorAdapterActionParams({ + connectorAdapterRegistry: registry, + params: {}, + }) + ).not.toThrow(); + }); + + it('should not throw if the connector adapter is not registered', () => { + expect(() => + validateConnectorAdapterActionParams({ + connectorAdapterRegistry: registry, + connectorTypeId: firstConnectorAdapter.connectorTypeId, + params: {}, + }) + ).not.toThrow(); + }); + }); + + describe('bulkValidateConnectorAdapterActionParams', () => { + it('should validate correctly invalid params with multiple actions', () => { + const actions = [ + { actionTypeId: firstConnectorAdapter.connectorTypeId, params: { foo: 5 } }, + { actionTypeId: secondConnectorAdapter.connectorTypeId, params: { bar: 'test' } }, + ]; + + registry.register(firstConnectorAdapter); + registry.register(secondConnectorAdapter); + + expect(() => + bulkValidateConnectorAdapterActionParams({ + connectorAdapterRegistry: registry, + actions, + }) + ).toThrowErrorMatchingInlineSnapshot( + `"Invalid system action params. System action type: .test - [foo]: expected value of type [string] but got [number]"` + ); + }); + }); +}); diff --git a/x-pack/plugins/alerting/server/connector_adapters/validate_rule_action_params.ts b/x-pack/plugins/alerting/server/connector_adapters/validate_rule_action_params.ts new file mode 100644 index 0000000000000..abfd56f0e9079 --- /dev/null +++ b/x-pack/plugins/alerting/server/connector_adapters/validate_rule_action_params.ts @@ -0,0 +1,58 @@ +/* + * 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 Boom from '@hapi/boom'; +import { ConnectorAdapterRegistry } from './connector_adapter_registry'; + +interface ValidateSchemaArgs { + connectorAdapterRegistry: ConnectorAdapterRegistry; + connectorTypeId?: string; + params: Record; +} + +interface BulkValidateSchemaArgs { + connectorAdapterRegistry: ConnectorAdapterRegistry; + actions: Array<{ actionTypeId: string; params: Record }>; +} + +export const validateConnectorAdapterActionParams = ({ + connectorAdapterRegistry, + connectorTypeId, + params, +}: ValidateSchemaArgs) => { + if (!connectorTypeId) { + return; + } + + if (!connectorAdapterRegistry.has(connectorTypeId)) { + return; + } + + const connectorAdapter = connectorAdapterRegistry.get(connectorTypeId); + const schema = connectorAdapter.ruleActionParamsSchema; + + try { + schema.validate(params); + } catch (error) { + throw Boom.badRequest( + `Invalid system action params. System action type: ${connectorAdapter.connectorTypeId} - ${error.message}` + ); + } +}; + +export const bulkValidateConnectorAdapterActionParams = ({ + connectorAdapterRegistry, + actions, +}: BulkValidateSchemaArgs) => { + for (const action of actions) { + validateConnectorAdapterActionParams({ + connectorAdapterRegistry, + connectorTypeId: action.actionTypeId, + params: action.params, + }); + } +}; diff --git a/x-pack/plugins/alerting/server/data/rule/types/rule_attributes.ts b/x-pack/plugins/alerting/server/data/rule/types/rule_attributes.ts index 03ccf7c8a6b0d..239422d5ad28a 100644 --- a/x-pack/plugins/alerting/server/data/rule/types/rule_attributes.ts +++ b/x-pack/plugins/alerting/server/data/rule/types/rule_attributes.ts @@ -146,7 +146,7 @@ interface AlertsFilterAttributes { export interface RuleActionAttributes { uuid: string; - group: string; + group?: string; actionRef: string; actionTypeId: string; params: SavedObjectAttributes; diff --git a/x-pack/plugins/alerting/server/index.ts b/x-pack/plugins/alerting/server/index.ts index ae42c665d73cf..4dacaf50cedce 100644 --- a/x-pack/plugins/alerting/server/index.ts +++ b/x-pack/plugins/alerting/server/index.ts @@ -66,6 +66,7 @@ export { installWithTimeout, } from './alerts_service'; export { getDataStreamAdapter } from './alerts_service/lib/data_stream_adapter'; +export type { ConnectorAdapter } from './connector_adapters/types'; export const plugin = (initContext: PluginInitializerContext) => new AlertingPlugin(initContext); diff --git a/x-pack/plugins/alerting/server/lib/validate_system_actions.test.ts b/x-pack/plugins/alerting/server/lib/validate_system_actions.test.ts new file mode 100644 index 0000000000000..de3a45d5c09b9 --- /dev/null +++ b/x-pack/plugins/alerting/server/lib/validate_system_actions.test.ts @@ -0,0 +1,187 @@ +/* + * 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 { ActionsClient } from '@kbn/actions-plugin/server'; +import { actionsClientMock } from '@kbn/actions-plugin/server/mocks'; +import { schema } from '@kbn/config-schema'; +import { ConnectorAdapterRegistry } from '../connector_adapters/connector_adapter_registry'; +import { ConnectorAdapter } from '../connector_adapters/types'; +import { NormalizedSystemAction } from '../rules_client'; +import { RuleActionTypes, RuleSystemAction } from '../types'; +import { validateSystemActions } from './validate_system_actions'; + +describe('validateSystemActions', () => { + const connectorAdapter: ConnectorAdapter = { + connectorTypeId: '.test', + ruleActionParamsSchema: schema.object({ foo: schema.string() }), + buildActionParams: jest.fn(), + }; + + let registry: ConnectorAdapterRegistry; + let actionsClient: jest.Mocked; + + beforeEach(() => { + registry = new ConnectorAdapterRegistry(); + actionsClient = actionsClientMock.create(); + actionsClient.getBulk.mockResolvedValue([ + { + id: 'system_action-id', + actionTypeId: '.test', + config: {}, + isMissingSecrets: false, + name: 'system action connector', + isPreconfigured: false, + isDeprecated: false, + isSystemAction: true, + }, + ]); + }); + + it('should not validate with empty system actions', async () => { + const res = await validateSystemActions({ + connectorAdapterRegistry: registry, + systemActions: [], + actionsClient, + }); + + expect(res).toBe(undefined); + expect(actionsClient.getBulk).not.toBeCalled(); + expect(actionsClient.isSystemAction).not.toBeCalled(); + }); + + it('should throw an error if the action is not a system action even if it is declared as one', async () => { + const systemActions: RuleSystemAction[] = [ + { + id: 'not-exist', + uuid: '123', + params: { foo: 'test' }, + actionTypeId: '.test', + type: RuleActionTypes.SYSTEM, + }, + ]; + + registry.register(connectorAdapter); + + actionsClient.isSystemAction.mockReturnValue(false); + + await expect(() => + validateSystemActions({ + connectorAdapterRegistry: registry, + systemActions, + actionsClient, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"Action not-exist is not a system action"`); + }); + + it('should throw an error if the action is system action but is not returned from the actions client (getBulk)', async () => { + const systemActions: RuleSystemAction[] = [ + { + id: 'not-exist', + uuid: '123', + params: { foo: 'test' }, + actionTypeId: '.test', + type: RuleActionTypes.SYSTEM, + }, + ]; + + registry.register(connectorAdapter); + + actionsClient.isSystemAction.mockReturnValue(true); + + await expect(() => + validateSystemActions({ + connectorAdapterRegistry: registry, + systemActions, + actionsClient, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"Action not-exist is not a system action"`); + }); + + it('should throw an error if the params are not valid', async () => { + const systemActions: RuleSystemAction[] = [ + { + id: 'system_action-id', + uuid: '123', + params: { 'not-exist': 'test' }, + actionTypeId: '.test', + type: RuleActionTypes.SYSTEM, + }, + ]; + + registry.register(connectorAdapter); + + actionsClient.isSystemAction.mockReturnValue(true); + + await expect(() => + validateSystemActions({ + connectorAdapterRegistry: registry, + systemActions, + actionsClient, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Invalid system action params. System action type: .test - [foo]: expected value of type [string] but got [undefined]"` + ); + }); + + it('should call getBulk correctly', async () => { + const systemActions: Array = [ + { + id: 'system_action-id', + uuid: '123', + params: { foo: 'test' }, + type: RuleActionTypes.SYSTEM, + }, + { + id: 'system_action-id-2', + uuid: '123', + params: { foo: 'test' }, + actionTypeId: '.test', + type: RuleActionTypes.SYSTEM, + }, + ]; + + actionsClient.getBulk.mockResolvedValue([ + { + id: 'system_action-id', + actionTypeId: '.test', + config: {}, + isMissingSecrets: false, + name: 'system action connector', + isPreconfigured: false, + isDeprecated: false, + isSystemAction: true, + }, + { + id: 'system_action-id-2', + actionTypeId: '.test', + config: {}, + isMissingSecrets: false, + name: 'system action connector 2', + isPreconfigured: false, + isDeprecated: false, + isSystemAction: true, + }, + ]); + + registry.register(connectorAdapter); + + actionsClient.isSystemAction.mockReturnValue(true); + + const res = await validateSystemActions({ + connectorAdapterRegistry: registry, + systemActions, + actionsClient, + }); + + expect(res).toBe(undefined); + + expect(actionsClient.getBulk).toBeCalledWith({ + ids: ['system_action-id', 'system_action-id-2'], + throwIfSystemAction: false, + }); + }); +}); diff --git a/x-pack/plugins/alerting/server/lib/validate_system_actions.ts b/x-pack/plugins/alerting/server/lib/validate_system_actions.ts new file mode 100644 index 0000000000000..ebe9426297ca2 --- /dev/null +++ b/x-pack/plugins/alerting/server/lib/validate_system_actions.ts @@ -0,0 +1,59 @@ +/* + * 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 Boom from '@hapi/boom'; +import { ActionsClient } from '@kbn/actions-plugin/server'; +import { ConnectorAdapterRegistry } from '../connector_adapters/connector_adapter_registry'; +import { bulkValidateConnectorAdapterActionParams } from '../connector_adapters/validate_rule_action_params'; +import { NormalizedSystemAction } from '../rules_client'; +import { RuleSystemAction } from '../types'; +interface Params { + actionsClient: ActionsClient; + connectorAdapterRegistry: ConnectorAdapterRegistry; + systemActions: Array; +} + +export const validateSystemActions = async ({ + actionsClient, + connectorAdapterRegistry, + systemActions, +}: Params) => { + if (systemActions.length === 0) { + return; + } + + /** + * When updating or creating a rule the actions may not contain + * the actionTypeId. We need to getBulk using the + * actionsClient to get the actionTypeId of each action. + * The actionTypeId is needed to get the schema of + * the action params using the connector adapter registry + */ + const actionIds: string[] = systemActions.map((action) => action.id); + + const actionResults = await actionsClient.getBulk({ ids: actionIds, throwIfSystemAction: false }); + const systemActionsWithActionTypeId: RuleSystemAction[] = []; + + for (const systemAction of systemActions) { + const isSystemAction = actionsClient.isSystemAction(systemAction.id); + const foundAction = actionResults.find((actionRes) => actionRes.id === systemAction.id); + + if (!isSystemAction || !foundAction) { + throw Boom.badRequest(`Action ${systemAction.id} is not a system action`); + } + + systemActionsWithActionTypeId.push({ + ...systemAction, + actionTypeId: foundAction.actionTypeId, + }); + } + + bulkValidateConnectorAdapterActionParams({ + connectorAdapterRegistry, + actions: systemActionsWithActionTypeId, + }); +}; diff --git a/x-pack/plugins/alerting/server/plugin.test.ts b/x-pack/plugins/alerting/server/plugin.test.ts index c92207a05819c..45f2a19770ea0 100644 --- a/x-pack/plugins/alerting/server/plugin.test.ts +++ b/x-pack/plugins/alerting/server/plugin.test.ts @@ -27,7 +27,6 @@ import { PluginSetup as DataPluginSetup, } from '@kbn/data-plugin/server'; import { spacesMock } from '@kbn/spaces-plugin/server/mocks'; -import { schema } from '@kbn/config-schema'; import { serverlessPluginMock } from '@kbn/serverless/server/mocks'; import { AlertsService } from './alerts_service/alerts_service'; import { alertsServiceMock } from './alerts_service/alerts_service.mock'; @@ -39,6 +38,7 @@ jest.mock('./alerts_service/alerts_service', () => ({ import { SharePluginStart } from '@kbn/share-plugin/server'; import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; import { generateAlertingConfig } from './test_utils'; +import { schema } from '@kbn/config-schema'; const sampleRuleType: RuleType = { id: 'test', diff --git a/x-pack/plugins/alerting/server/routes/_mock_handler_arguments.ts b/x-pack/plugins/alerting/server/routes/_mock_handler_arguments.ts index 8673614ed2ec0..055c4106a2c1e 100644 --- a/x-pack/plugins/alerting/server/routes/_mock_handler_arguments.ts +++ b/x-pack/plugins/alerting/server/routes/_mock_handler_arguments.ts @@ -9,6 +9,8 @@ import { KibanaRequest, KibanaResponseFactory } from '@kbn/core/server'; import { identity } from 'lodash'; import type { MethodKeysOf } from '@kbn/utility-types'; import { httpServerMock } from '@kbn/core/server/mocks'; +import { actionsClientMock } from '@kbn/actions-plugin/server/mocks'; +import { ActionsClientMock } from '@kbn/actions-plugin/server/actions_client/actions_client.mock'; import { rulesClientMock, RulesClientMock } from '../rules_client.mock'; import { rulesSettingsClientMock, RulesSettingsClientMock } from '../rules_settings_client.mock'; import { @@ -26,6 +28,7 @@ export function mockHandlerArguments( listTypes: listTypesRes = [], getFrameworkHealth, areApiKeysEnabled, + actionsClient = actionsClientMock.create(), }: { rulesClient?: RulesClientMock; rulesSettingsClient?: RulesSettingsClientMock; @@ -34,6 +37,7 @@ export function mockHandlerArguments( getFrameworkHealth?: jest.MockInstance, []> & (() => Promise); areApiKeysEnabled?: () => Promise; + actionsClient?: ActionsClientMock; }, request: unknown, response?: Array> @@ -59,6 +63,11 @@ export function mockHandlerArguments( getFrameworkHealth, areApiKeysEnabled: areApiKeysEnabled ? areApiKeysEnabled : () => Promise.resolve(true), }, + actions: { + getActionsClient() { + return actionsClient ?? actionsClientMock.create(); + }, + }, } as unknown as AlertingRequestHandlerContext, request as KibanaRequest, mockResponseFactory(response), diff --git a/x-pack/plugins/alerting/server/routes/clone_rule.test.ts b/x-pack/plugins/alerting/server/routes/clone_rule.test.ts index df090abee9bad..c66a27b49ff39 100644 --- a/x-pack/plugins/alerting/server/routes/clone_rule.test.ts +++ b/x-pack/plugins/alerting/server/routes/clone_rule.test.ts @@ -13,7 +13,13 @@ import { mockHandlerArguments } from './_mock_handler_arguments'; import { rulesClientMock } from '../rules_client.mock'; import { RuleTypeDisabledError } from '../lib/errors/rule_type_disabled'; import { cloneRuleRoute } from './clone_rule'; -import { SanitizedRule } from '../types'; +import { + PartialRuleResponse, + RuleActionTypes, + RuleDefaultAction, + RuleSystemAction, + SanitizedRule, +} from '../types'; import { AsApiContract } from './lib'; const rulesClient = rulesClientMock.create(); @@ -28,6 +34,26 @@ beforeEach(() => { describe('cloneRuleRoute', () => { const createdAt = new Date(); const updatedAt = new Date(); + const action: RuleDefaultAction = { + actionTypeId: 'test', + group: 'default', + id: '2', + params: { + foo: true, + }, + uuid: '123-456', + type: RuleActionTypes.DEFAULT, + }; + + const systemAction: RuleSystemAction = { + actionTypeId: 'test-2', + id: 'system_action-id', + params: { + foo: true, + }, + uuid: '123-456', + type: RuleActionTypes.SYSTEM, + }; const mockedRule: SanitizedRule<{ bar: boolean }> = { alertTypeId: '1', @@ -39,17 +65,7 @@ describe('cloneRuleRoute', () => { bar: true, }, throttle: '30s', - actions: [ - { - actionTypeId: 'test', - group: 'default', - id: '2', - params: { - foo: true, - }, - uuid: '123-456', - }, - ], + actions: [action], enabled: true, muteAll: false, createdBy: '', @@ -73,14 +89,14 @@ describe('cloneRuleRoute', () => { notify_when: mockedRule.notifyWhen, actions: [ { - group: mockedRule.actions[0].group, + group: action.group, id: mockedRule.actions[0].id, params: mockedRule.actions[0].params, }, ], }; - const cloneResult: AsApiContract> = { + const cloneResult: AsApiContract> = { ...ruleToClone, mute_all: mockedRule.muteAll, created_by: mockedRule.createdBy, @@ -214,4 +230,49 @@ describe('cloneRuleRoute', () => { expect(res.forbidden).toHaveBeenCalledWith({ body: { message: 'Fail' } }); }); + + describe('actions', () => { + it('removes the type from the actions correctly before sending the response', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + cloneRuleRoute(router, licenseState); + + const [_, handler] = router.post.mock.calls[0]; + rulesClient.clone.mockResolvedValueOnce({ ...mockedRule, actions: [action, systemAction] }); + + const [context, req, res] = mockHandlerArguments( + { rulesClient }, + { + params: { + id: '1', + }, + }, + ['ok'] + ); + + const routeRes = await handler(context, req, res); + + // @ts-expect-error: body exists + expect(routeRes.body.actions).toEqual([ + { + connector_type_id: 'test', + group: 'default', + id: '2', + params: { + foo: true, + }, + uuid: '123-456', + }, + { + connector_type_id: 'test-2', + id: 'system_action-id', + params: { + foo: true, + }, + uuid: '123-456', + }, + ]); + }); + }); }); diff --git a/x-pack/plugins/alerting/server/routes/clone_rule.ts b/x-pack/plugins/alerting/server/routes/clone_rule.ts index fa775e626d903..52a1acf604c47 100644 --- a/x-pack/plugins/alerting/server/routes/clone_rule.ts +++ b/x-pack/plugins/alerting/server/routes/clone_rule.ts @@ -10,16 +10,17 @@ import { IRouter } from '@kbn/core/server'; import { ILicenseState, RuleTypeDisabledError } from '../lib'; import { verifyAccessAndContext, - RewriteResponseCase, handleDisabledApiKeysError, rewriteRuleLastRun, rewriteActionsRes, + AsApiContract, } from './lib'; import { RuleTypeParams, AlertingRequestHandlerContext, INTERNAL_BASE_ALERTING_API_PATH, PartialRule, + PartialRuleResponse, } from '../types'; const paramSchema = schema.object({ @@ -27,7 +28,7 @@ const paramSchema = schema.object({ newId: schema.maybe(schema.string()), }); -const rewriteBodyRes: RewriteResponseCase> = ({ +const rewriteBodyRes = ({ actions, alertTypeId, scheduledTaskId, @@ -46,7 +47,7 @@ const rewriteBodyRes: RewriteResponseCase> = ({ lastRun, nextRun, ...rest -}) => ({ +}: PartialRule): AsApiContract> => ({ ...rest, api_key_owner: apiKeyOwner, created_by: createdBy, diff --git a/x-pack/plugins/alerting/server/routes/find_rules.test.ts b/x-pack/plugins/alerting/server/routes/find_rules.test.ts index 6e8f4e5474dbf..38e0497a876d2 100644 --- a/x-pack/plugins/alerting/server/routes/find_rules.test.ts +++ b/x-pack/plugins/alerting/server/routes/find_rules.test.ts @@ -4,6 +4,8 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ + +import { pick } from 'lodash'; import { usageCountersServiceMock } from '@kbn/usage-collection-plugin/server/usage_counters/usage_counters_service.mock'; import { findRulesRoute } from './find_rules'; import { httpServiceMock } from '@kbn/core/server/mocks'; @@ -12,6 +14,7 @@ import { verifyApiAccess } from '../lib/license_api_access'; import { mockHandlerArguments } from './_mock_handler_arguments'; import { rulesClientMock } from '../rules_client.mock'; import { trackLegacyTerminology } from './lib/track_legacy_terminology'; +import { RuleActionTypes } from '../types'; const rulesClient = rulesClientMock.create(); const mockUsageCountersSetup = usageCountersServiceMock.createSetupContract(); @@ -30,6 +33,96 @@ beforeEach(() => { }); describe('findRulesRoute', () => { + const action = { + actionTypeId: 'test', + group: 'default', + id: '2', + params: { + foo: true, + }, + type: RuleActionTypes.DEFAULT, + }; + + const systemAction = { + actionTypeId: 'test-2', + id: 'system_action-id', + params: { + foo: true, + }, + type: RuleActionTypes.SYSTEM, + }; + + const rule = { + alertTypeId: '1', + consumer: 'bar', + name: 'abc', + schedule: { interval: '10s' }, + tags: ['foo'], + params: { + bar: true, + }, + throttle: '30s', + actions: [action], + enabled: true, + muteAll: false, + createdBy: '', + updatedBy: '', + apiKeyOwner: '', + mutedInstanceIds: [], + notifyWhen: 'onActionGroupChange' as const, + createdAt: new Date(), + updatedAt: new Date(), + id: '123', + executionStatus: { + status: 'unknown' as const, + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + }, + revision: 0, + }; + + const rulesClientResponse = { + page: 1, + perPage: 1, + total: 0, + data: [rule], + }; + + const findResponse = { + page: 1, + per_page: 1, + total: 0, + data: [ + { + ...pick(rule, 'consumer', 'name', 'schedule', 'tags', 'params', 'throttle', 'enabled'), + rule_type_id: rule.alertTypeId, + notify_when: rule.notifyWhen, + mute_all: rule.muteAll, + created_by: rule.createdBy, + updated_by: rule.updatedBy, + api_key_owner: rule.apiKeyOwner, + muted_alert_ids: rule.mutedInstanceIds, + created_at: rule.createdAt, + updated_at: rule.updatedAt, + id: rule.id, + revision: rule.revision, + execution_status: { + status: rule.executionStatus.status, + last_execution_date: rule.executionStatus.lastExecutionDate, + }, + actions: [ + { + connector_type_id: 'test', + group: 'default', + id: '2', + params: { + foo: true, + }, + }, + ], + }, + ], + }; + it('finds rules with proper parameters', async () => { const licenseState = licenseStateMock.create(); const router = httpServiceMock.createRouter(); @@ -40,13 +133,7 @@ describe('findRulesRoute', () => { expect(config.path).toMatchInlineSnapshot(`"/api/alerting/rules/_find"`); - const findResult = { - page: 1, - perPage: 1, - total: 0, - data: [], - }; - rulesClient.find.mockResolvedValueOnce(findResult); + rulesClient.find.mockResolvedValueOnce(rulesClientResponse); const [context, req, res] = mockHandlerArguments( { rulesClient }, @@ -60,16 +147,7 @@ describe('findRulesRoute', () => { ['ok'] ); - expect(await handler(context, req, res)).toMatchInlineSnapshot(` - Object { - "body": Object { - "data": Array [], - "page": 1, - "per_page": 1, - "total": 0, - }, - } - `); + await handler(context, req, res); expect(rulesClient.find).toHaveBeenCalledTimes(1); expect(rulesClient.find.mock.calls[0]).toMatchInlineSnapshot(` @@ -87,12 +165,7 @@ describe('findRulesRoute', () => { `); expect(res.ok).toHaveBeenCalledWith({ - body: { - page: 1, - per_page: 1, - total: 0, - data: [], - }, + body: findResponse, }); }); @@ -104,12 +177,7 @@ describe('findRulesRoute', () => { const [, handler] = router.get.mock.calls[0]; - rulesClient.find.mockResolvedValueOnce({ - page: 1, - perPage: 1, - total: 0, - data: [], - }); + rulesClient.find.mockResolvedValueOnce(rulesClientResponse); const [context, req, res] = mockHandlerArguments( { rulesClient }, @@ -160,14 +228,11 @@ describe('findRulesRoute', () => { const router = httpServiceMock.createRouter(); findRulesRoute(router, licenseState, mockUsageCounter); - const findResult = { - page: 1, - perPage: 1, - total: 0, - data: [], - }; - rulesClient.find.mockResolvedValueOnce(findResult); + + rulesClient.find.mockResolvedValueOnce(rulesClientResponse); + const [, handler] = router.get.mock.calls[0]; + const [context, req, res] = mockHandlerArguments( { rulesClient }, { @@ -180,7 +245,9 @@ describe('findRulesRoute', () => { }, ['ok'] ); + await handler(context, req, res); + expect(trackLegacyTerminology).toHaveBeenCalledTimes(1); expect((trackLegacyTerminology as jest.Mock).mock.calls[0][0]).toStrictEqual([ 'alertTypeId:2', @@ -195,13 +262,7 @@ describe('findRulesRoute', () => { findRulesRoute(router, licenseState, mockUsageCounter); - const findResult = { - page: 1, - perPage: 1, - total: 0, - data: [], - }; - rulesClient.find.mockResolvedValueOnce(findResult); + rulesClient.find.mockResolvedValueOnce(rulesClientResponse); const [, handler] = router.get.mock.calls[0]; const [context, req, res] = mockHandlerArguments( @@ -217,11 +278,60 @@ describe('findRulesRoute', () => { }, ['ok'] ); + await handler(context, req, res); + expect(mockUsageCounter.incrementCounter).toHaveBeenCalledWith({ counterName: `alertingFieldsUsage`, counterType: 'alertingFieldsUsage', incrementBy: 1, }); }); + + describe('actions', () => { + it('removes the type from the actions correctly before sending the response', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + findRulesRoute(router, licenseState, mockUsageCounter); + + rulesClient.find.mockResolvedValueOnce({ + ...rulesClientResponse, + data: [{ ...rule, actions: [action, systemAction] }], + }); + + const [, handler] = router.get.mock.calls[0]; + const [context, req, res] = mockHandlerArguments( + { rulesClient }, + { + params: {}, + query: { + fields: ['foo', 'bar'], + }, + }, + ['ok'] + ); + + const routeRes = await handler(context, req, res); + + // @ts-expect-error: body exists + expect(routeRes.body.data[0].actions).toEqual([ + { + connector_type_id: 'test', + group: 'default', + id: '2', + params: { + foo: true, + }, + }, + { + connector_type_id: 'test-2', + id: 'system_action-id', + params: { + foo: true, + }, + }, + ]); + }); + }); }); diff --git a/x-pack/plugins/alerting/server/routes/find_rules.ts b/x-pack/plugins/alerting/server/routes/find_rules.ts index 04b18da1a1b0c..3b134045245cf 100644 --- a/x-pack/plugins/alerting/server/routes/find_rules.ts +++ b/x-pack/plugins/alerting/server/routes/find_rules.ts @@ -10,12 +10,7 @@ import { UsageCounter } from '@kbn/usage-collection-plugin/server'; import { schema } from '@kbn/config-schema'; import { ILicenseState } from '../lib'; import { FindOptions, FindResult } from '../rules_client'; -import { - RewriteRequestCase, - RewriteResponseCase, - verifyAccessAndContext, - rewriteRule, -} from './lib'; +import { RewriteRequestCase, verifyAccessAndContext, rewriteRule } from './lib'; import { RuleTypeParams, AlertingRequestHandlerContext, @@ -66,11 +61,8 @@ const rewriteQueryReq: RewriteRequestCase = ({ ...(hasReference ? { hasReference } : {}), ...(searchFields ? { searchFields } : {}), }); -const rewriteBodyRes: RewriteResponseCase> = ({ - perPage, - data, - ...restOfResult -}) => { + +const rewriteBodyRes = ({ perPage, data, ...restOfResult }: FindResult) => { return { ...restOfResult, per_page: perPage, diff --git a/x-pack/plugins/alerting/server/routes/get_rule.test.ts b/x-pack/plugins/alerting/server/routes/get_rule.test.ts index a672de9cbf320..bf131d6f82b17 100644 --- a/x-pack/plugins/alerting/server/routes/get_rule.test.ts +++ b/x-pack/plugins/alerting/server/routes/get_rule.test.ts @@ -12,7 +12,13 @@ import { licenseStateMock } from '../lib/license_state.mock'; import { verifyApiAccess } from '../lib/license_api_access'; import { mockHandlerArguments } from './_mock_handler_arguments'; import { rulesClientMock } from '../rules_client.mock'; -import { SanitizedRule } from '../types'; +import { + RuleActionTypes, + RuleDefaultAction, + RuleSystemAction, + SanitizedRule, + SanitizedRuleResponse, +} from '../types'; import { AsApiContract } from './lib'; const rulesClient = rulesClientMock.create(); @@ -25,6 +31,39 @@ beforeEach(() => { }); describe('getRuleRoute', () => { + const action: RuleDefaultAction = { + group: 'default', + id: '2', + actionTypeId: 'test', + params: { + foo: true, + }, + uuid: '123-456', + alertsFilter: { + query: { + kql: 'name:test', + dsl: '{"must": {"term": { "name": "test" }}}', + filters: [], + }, + timeframe: { + days: [1], + hours: { start: '08:00', end: '17:00' }, + timezone: 'UTC', + }, + }, + type: RuleActionTypes.DEFAULT, + }; + + const systemAction: RuleSystemAction = { + actionTypeId: 'test-2', + id: 'system_action-id', + params: { + foo: true, + }, + uuid: '123-456', + type: RuleActionTypes.SYSTEM, + }; + const mockedAlert: SanitizedRule<{ bar: boolean; }> = { @@ -36,29 +75,7 @@ describe('getRuleRoute', () => { }, createdAt: new Date(), updatedAt: new Date(), - actions: [ - { - group: 'default', - id: '2', - actionTypeId: 'test', - params: { - foo: true, - }, - uuid: '123-456', - alertsFilter: { - query: { - kql: 'name:test', - dsl: '{"must": {"term": { "name": "test" }}}', - filters: [], - }, - timeframe: { - days: [1], - hours: { start: '08:00', end: '17:00' }, - timezone: 'UTC', - }, - }, - }, - ], + actions: [action], consumer: 'bar', name: 'abc', tags: ['foo'], @@ -77,7 +94,7 @@ describe('getRuleRoute', () => { revision: 0, }; - const getResult: AsApiContract> = { + const getResult: AsApiContract> = { ...pick(mockedAlert, 'consumer', 'name', 'schedule', 'tags', 'params', 'throttle', 'enabled'), rule_type_id: mockedAlert.alertTypeId, notify_when: mockedAlert.notifyWhen, @@ -96,12 +113,12 @@ describe('getRuleRoute', () => { }, actions: [ { - group: mockedAlert.actions[0].group, + group: action.group, id: mockedAlert.actions[0].id, params: mockedAlert.actions[0].params, connector_type_id: mockedAlert.actions[0].actionTypeId, uuid: mockedAlert.actions[0].uuid, - alerts_filter: mockedAlert.actions[0].alertsFilter, + alerts_filter: action.alertsFilter, }, ], }; @@ -183,4 +200,64 @@ describe('getRuleRoute', () => { expect(verifyApiAccess).toHaveBeenCalledWith(licenseState); }); + + describe('actions', () => { + it('removes the type from the actions correctly before sending the response', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + getRuleRoute(router, licenseState); + + const [_, handler] = router.get.mock.calls[0]; + rulesClient.get.mockResolvedValueOnce({ ...mockedAlert, actions: [action, systemAction] }); + + const [context, req, res] = mockHandlerArguments( + { rulesClient }, + { + params: { + id: '1', + }, + }, + ['ok'] + ); + + const routeRes = await handler(context, req, res); + + // @ts-expect-error: body exists + expect(routeRes.body.actions).toEqual([ + { + alerts_filter: { + query: { + dsl: '{"must": {"term": { "name": "test" }}}', + filters: [], + kql: 'name:test', + }, + timeframe: { + days: [1], + hours: { + end: '17:00', + start: '08:00', + }, + timezone: 'UTC', + }, + }, + connector_type_id: 'test', + group: 'default', + id: '2', + params: { + foo: true, + }, + uuid: '123-456', + }, + { + connector_type_id: 'test-2', + id: 'system_action-id', + params: { + foo: true, + }, + uuid: '123-456', + }, + ]); + }); + }); }); diff --git a/x-pack/plugins/alerting/server/routes/get_rule.ts b/x-pack/plugins/alerting/server/routes/get_rule.ts index bc7983f6acdf1..c8d533350cfd6 100644 --- a/x-pack/plugins/alerting/server/routes/get_rule.ts +++ b/x-pack/plugins/alerting/server/routes/get_rule.ts @@ -11,9 +11,9 @@ import { IRouter } from '@kbn/core/server'; import { ILicenseState } from '../lib'; import { verifyAccessAndContext, - RewriteResponseCase, rewriteRuleLastRun, rewriteActionsRes, + AsApiContract, } from './lib'; import { RuleTypeParams, @@ -21,13 +21,14 @@ import { BASE_ALERTING_API_PATH, INTERNAL_BASE_ALERTING_API_PATH, SanitizedRule, + SanitizedRuleResponse, } from '../types'; const paramSchema = schema.object({ id: schema.string(), }); -const rewriteBodyRes: RewriteResponseCase> = ({ +const rewriteBodyRes = ({ alertTypeId, createdBy, updatedBy, @@ -47,7 +48,7 @@ const rewriteBodyRes: RewriteResponseCase> = ({ nextRun, viewInAppRelativeUrl, ...rest -}) => ({ +}: SanitizedRule): AsApiContract> => ({ ...rest, rule_type_id: alertTypeId, created_by: createdBy, diff --git a/x-pack/plugins/alerting/server/routes/legacy/create.test.ts b/x-pack/plugins/alerting/server/routes/legacy/create.test.ts index 548015ef55f03..5eb17e18d3d9d 100644 --- a/x-pack/plugins/alerting/server/routes/legacy/create.test.ts +++ b/x-pack/plugins/alerting/server/routes/legacy/create.test.ts @@ -12,10 +12,12 @@ import { licenseStateMock } from '../../lib/license_state.mock'; import { verifyApiAccess } from '../../lib/license_api_access'; import { mockHandlerArguments } from '../_mock_handler_arguments'; import { rulesClientMock } from '../../rules_client.mock'; -import { Rule } from '../../../common/rule'; +import { RuleActionTypes } from '../../../common/rule'; import { RuleTypeDisabledError } from '../../lib/errors/rule_type_disabled'; import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks'; import { trackLegacyRouteUsage } from '../../lib/track_legacy_route_usage'; +import { actionsClientMock } from '@kbn/actions-plugin/server/mocks'; +import { omit } from 'lodash'; const rulesClient = rulesClientMock.create(); @@ -35,7 +37,26 @@ describe('createAlertRoute', () => { const createdAt = new Date(); const updatedAt = new Date(); - const mockedAlert = { + const action = { + actionTypeId: 'test', + group: 'default', + id: '2', + params: { + foo: true, + }, + type: RuleActionTypes.DEFAULT, + }; + + const systemAction = { + actionTypeId: 'test-2', + id: 'system_action-id', + params: { + foo: true, + }, + type: RuleActionTypes.SYSTEM, + }; + + const createRequest = { alertTypeId: '1', consumer: 'bar', name: 'abc', @@ -45,7 +66,7 @@ describe('createAlertRoute', () => { bar: true, }, throttle: '30s', - notifyWhen: 'onActionGroupChange', + notifyWhen: 'onActionGroupChange' as const, actions: [ { group: 'default', @@ -57,8 +78,8 @@ describe('createAlertRoute', () => { ], }; - const createResult: Rule<{ bar: boolean }> = { - ...mockedAlert, + const createResult = { + ...createRequest, enabled: true, muteAll: false, createdBy: '', @@ -66,29 +87,36 @@ describe('createAlertRoute', () => { apiKey: '', apiKeyOwner: '', mutedInstanceIds: [], - notifyWhen: 'onActionGroupChange', + notifyWhen: 'onActionGroupChange' as const, createdAt, updatedAt, id: '123', actions: [ { - ...mockedAlert.actions[0], + ...createRequest.actions[0], actionTypeId: 'test', }, ], executionStatus: { - status: 'unknown', + status: 'unknown' as const, lastExecutionDate: new Date('2020-08-20T19:23:38Z'), }, revision: 0, }; + const mockedResponse = { + ...createResult, + actions: [action], + }; + it('creates an alert with proper parameters', async () => { const licenseState = licenseStateMock.create(); const router = httpServiceMock.createRouter(); const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup({ canEncrypt: true }); const mockUsageCountersSetup = usageCountersServiceMock.createSetupContract(); const mockUsageCounter = mockUsageCountersSetup.createUsageCounter('test'); + const actionsClient = actionsClientMock.create(); + actionsClient.isSystemAction.mockImplementation((id: string) => id === 'system_action-id'); createAlertRoute({ router, @@ -101,12 +129,12 @@ describe('createAlertRoute', () => { expect(config.path).toMatchInlineSnapshot(`"/api/alerts/alert/{id?}"`); - rulesClient.create.mockResolvedValueOnce(createResult); + rulesClient.create.mockResolvedValueOnce(mockedResponse); const [context, req, res] = mockHandlerArguments( - { rulesClient }, + { rulesClient, actionsClient }, { - body: mockedAlert, + body: createRequest, }, ['ok'] ); @@ -126,6 +154,7 @@ describe('createAlertRoute', () => { "params": Object { "foo": true, }, + "type": "default", }, ], "alertTypeId": "1", @@ -165,6 +194,8 @@ describe('createAlertRoute', () => { const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup({ canEncrypt: true }); const mockUsageCountersSetup = usageCountersServiceMock.createSetupContract(); const mockUsageCounter = mockUsageCountersSetup.createUsageCounter('test'); + const actionsClient = actionsClientMock.create(); + actionsClient.isSystemAction.mockImplementation((id: string) => id === 'system_action-id'); createAlertRoute({ router, @@ -177,13 +208,13 @@ describe('createAlertRoute', () => { expect(config.path).toMatchInlineSnapshot(`"/api/alerts/alert/{id?}"`); - rulesClient.create.mockResolvedValueOnce(expectedResult); + rulesClient.create.mockResolvedValueOnce({ ...mockedResponse, id: 'custom-id' }); const [context, req, res] = mockHandlerArguments( - { rulesClient }, + { rulesClient, actionsClient }, { params: { id: 'custom-id' }, - body: mockedAlert, + body: createRequest, }, ['ok'] ); @@ -203,6 +234,7 @@ describe('createAlertRoute', () => { "params": Object { "foo": true, }, + "type": "default", }, ], "alertTypeId": "1", @@ -242,6 +274,8 @@ describe('createAlertRoute', () => { const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup({ canEncrypt: true }); const mockUsageCountersSetup = usageCountersServiceMock.createSetupContract(); const mockUsageCounter = mockUsageCountersSetup.createUsageCounter('test'); + const actionsClient = actionsClientMock.create(); + actionsClient.isSystemAction.mockImplementation((id: string) => id === 'system_action-id'); createAlertRoute({ router, @@ -254,14 +288,14 @@ describe('createAlertRoute', () => { expect(config.path).toMatchInlineSnapshot(`"/api/alerts/alert/{id?}"`); - rulesClient.create.mockResolvedValueOnce(expectedResult); + rulesClient.create.mockResolvedValueOnce({ ...mockedResponse, id: 'custom-id' }); rulesClient.getSpaceId.mockReturnValueOnce('default'); const [context, req, res] = mockHandlerArguments( - { rulesClient }, + { rulesClient, actionsClient }, { params: { id: 'custom-id' }, - body: mockedAlert, + body: createRequest, }, ['ok'] ); @@ -281,6 +315,7 @@ describe('createAlertRoute', () => { "params": Object { "foo": true, }, + "type": "default", }, ], "alertTypeId": "1", @@ -320,6 +355,8 @@ describe('createAlertRoute', () => { const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup({ canEncrypt: true }); const mockUsageCountersSetup = usageCountersServiceMock.createSetupContract(); const mockUsageCounter = mockUsageCountersSetup.createUsageCounter('test'); + const actionsClient = actionsClientMock.create(); + actionsClient.isSystemAction.mockImplementation((id: string) => id === 'system_action-id'); createAlertRoute({ router, @@ -332,14 +369,14 @@ describe('createAlertRoute', () => { expect(config.path).toMatchInlineSnapshot(`"/api/alerts/alert/{id?}"`); - rulesClient.create.mockResolvedValueOnce(expectedResult); + rulesClient.create.mockResolvedValueOnce({ ...mockedResponse, id: 'custom-id' }); rulesClient.getSpaceId.mockReturnValueOnce('another-space'); const [context, req, res] = mockHandlerArguments( - { rulesClient }, + { rulesClient, actionsClient }, { params: { id: 'custom-id' }, - body: mockedAlert, + body: createRequest, }, ['ok'] ); @@ -359,6 +396,7 @@ describe('createAlertRoute', () => { "params": Object { "foo": true, }, + "type": "default", }, ], "alertTypeId": "1", @@ -392,14 +430,22 @@ describe('createAlertRoute', () => { const licenseState = licenseStateMock.create(); const router = httpServiceMock.createRouter(); const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup({ canEncrypt: true }); + const actionsClient = actionsClientMock.create(); + actionsClient.isSystemAction.mockImplementation((id: string) => id === 'system_action-id'); createAlertRoute({ router, licenseState, encryptedSavedObjects }); const [, handler] = router.post.mock.calls[0]; - rulesClient.create.mockResolvedValueOnce(createResult); + rulesClient.create.mockResolvedValueOnce(mockedResponse); - const [context, req, res] = mockHandlerArguments({ rulesClient }, {}); + const [context, req, res] = mockHandlerArguments( + { rulesClient, actionsClient }, + { + params: { id: 'custom-id' }, + body: createRequest, + } + ); await handler(context, req, res); @@ -410,6 +456,8 @@ describe('createAlertRoute', () => { const licenseState = licenseStateMock.create(); const router = httpServiceMock.createRouter(); const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup({ canEncrypt: true }); + const actionsClient = actionsClientMock.create(); + actionsClient.isSystemAction.mockImplementation((id: string) => id === 'system_action-id'); (verifyApiAccess as jest.Mock).mockImplementation(() => { throw new Error('OMG'); @@ -419,9 +467,15 @@ describe('createAlertRoute', () => { const [, handler] = router.post.mock.calls[0]; - rulesClient.create.mockResolvedValueOnce(createResult); + rulesClient.create.mockResolvedValueOnce(mockedResponse); - const [context, req, res] = mockHandlerArguments({ rulesClient }, {}); + const [context, req, res] = mockHandlerArguments( + { rulesClient, actionsClient }, + { + params: { id: 'custom-id' }, + body: createRequest, + } + ); expect(handler(context, req, res)).rejects.toMatchInlineSnapshot(`[Error: OMG]`); @@ -432,6 +486,8 @@ describe('createAlertRoute', () => { const licenseState = licenseStateMock.create(); const router = httpServiceMock.createRouter(); const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup({ canEncrypt: true }); + const actionsClient = actionsClientMock.create(); + actionsClient.isSystemAction.mockImplementation((id: string) => id === 'system_action-id'); createAlertRoute({ router, licenseState, encryptedSavedObjects }); @@ -439,7 +495,14 @@ describe('createAlertRoute', () => { rulesClient.create.mockRejectedValue(new RuleTypeDisabledError('Fail', 'license_invalid')); - const [context, req, res] = mockHandlerArguments({ rulesClient }, {}, ['ok', 'forbidden']); + const [context, req, res] = mockHandlerArguments( + { rulesClient, actionsClient }, + { + params: { id: 'custom-id' }, + body: createRequest, + }, + ['ok', 'forbidden'] + ); await handler(context, req, res); @@ -452,6 +515,8 @@ describe('createAlertRoute', () => { const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup({ canEncrypt: true }); const mockUsageCountersSetup = usageCountersServiceMock.createSetupContract(); const mockUsageCounter = mockUsageCountersSetup.createUsageCounter('test'); + const actionsClient = actionsClientMock.create(); + actionsClient.isSystemAction.mockImplementation((id: string) => id === 'system_action-id'); createAlertRoute({ router, @@ -459,9 +524,220 @@ describe('createAlertRoute', () => { encryptedSavedObjects, usageCounter: mockUsageCounter, }); + const [, handler] = router.post.mock.calls[0]; - const [context, req, res] = mockHandlerArguments({ rulesClient }, {}, ['ok']); + + rulesClient.create.mockResolvedValueOnce(mockedResponse); + + const [context, req, res] = mockHandlerArguments( + { rulesClient, actionsClient }, + { + params: { id: 'custom-id' }, + body: createRequest, + }, + ['ok'] + ); + await handler(context, req, res); expect(trackLegacyRouteUsage).toHaveBeenCalledWith('create', mockUsageCounter); }); + + describe('actions', () => { + it('adds the type of the actions correctly before passing the request to the rules client', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup({ canEncrypt: true }); + const mockUsageCountersSetup = usageCountersServiceMock.createSetupContract(); + const mockUsageCounter = mockUsageCountersSetup.createUsageCounter('test'); + const actionsClient = actionsClientMock.create(); + actionsClient.isSystemAction.mockImplementation((id: string) => id === 'system_action-id'); + + createAlertRoute({ + router, + licenseState, + encryptedSavedObjects, + usageCounter: mockUsageCounter, + }); + + const [_, handler] = router.post.mock.calls[0]; + rulesClient.create.mockResolvedValueOnce({ + ...mockedResponse, + actions: [action, systemAction], + }); + + const [context, req, res] = mockHandlerArguments( + { rulesClient, actionsClient }, + { + params: { + id: '1', + }, + body: { ...createRequest, actions: [omit(action, 'type'), omit(systemAction, 'type')] }, + }, + ['ok'] + ); + + await handler(context, req, res); + + expect(rulesClient.create.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "data": Object { + "actions": Array [ + Object { + "group": "default", + "id": "2", + "params": Object { + "foo": true, + }, + "type": "default", + }, + Object { + "id": "system_action-id", + "params": Object { + "foo": true, + }, + "type": "system", + }, + ], + "alertTypeId": "1", + "consumer": "bar", + "name": "abc", + "notifyWhen": "onActionGroupChange", + "params": Object { + "bar": true, + }, + "schedule": Object { + "interval": "10s", + }, + "tags": Array [ + "foo", + ], + "throttle": "30s", + }, + "options": Object { + "id": "1", + }, + }, + ] + `); + }); + + it('removes the type from the actions correctly before sending the response', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup({ canEncrypt: true }); + const mockUsageCountersSetup = usageCountersServiceMock.createSetupContract(); + const mockUsageCounter = mockUsageCountersSetup.createUsageCounter('test'); + const actionsClient = actionsClientMock.create(); + actionsClient.isSystemAction.mockImplementation((id: string) => id === 'system_action-id'); + + createAlertRoute({ + router, + licenseState, + encryptedSavedObjects, + usageCounter: mockUsageCounter, + }); + + const [_, handler] = router.post.mock.calls[0]; + rulesClient.create.mockResolvedValueOnce({ + ...mockedResponse, + actions: [action, systemAction], + }); + + const [context, req, res] = mockHandlerArguments( + { rulesClient, actionsClient }, + { + params: { + id: '1', + }, + body: { ...createRequest, actions: [omit(action, 'type'), omit(systemAction, 'type')] }, + }, + ['ok'] + ); + + const routeRes = await handler(context, req, res); + + // @ts-expect-error: body exists + expect(routeRes.body.actions).toEqual([ + { + actionTypeId: 'test', + group: 'default', + id: '2', + params: { + foo: true, + }, + }, + { + actionTypeId: 'test-2', + id: 'system_action-id', + params: { + foo: true, + }, + }, + ]); + }); + + it('does not fails if the system action does not contain a group', async () => { + const actionToValidate = { + actionTypeId: 'test-2', + id: 'system_action-id', + params: { + foo: true, + }, + }; + + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup({ canEncrypt: true }); + const mockUsageCountersSetup = usageCountersServiceMock.createSetupContract(); + const mockUsageCounter = mockUsageCountersSetup.createUsageCounter('test'); + + createAlertRoute({ + router, + licenseState, + encryptedSavedObjects, + usageCounter: mockUsageCounter, + }); + + const [config, _] = router.post.mock.calls[0]; + + expect(() => + // @ts-expect-error: body exists + config.validate.body.validate({ ...createRequest, actions: [actionToValidate] }) + ).not.toThrow(); + }); + + it('fails if the action contains a type in the request', async () => { + const actionToValidate = { + group: 'default', + id: '2', + params: { + foo: true, + }, + type: RuleActionTypes.DEFAULT, + }; + + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup({ canEncrypt: true }); + const mockUsageCountersSetup = usageCountersServiceMock.createSetupContract(); + const mockUsageCounter = mockUsageCountersSetup.createUsageCounter('test'); + + createAlertRoute({ + router, + licenseState, + encryptedSavedObjects, + usageCounter: mockUsageCounter, + }); + + const [config, _] = router.post.mock.calls[0]; + + expect(() => + // @ts-expect-error: body exists + config.validate.body.validate({ ...createRequest, actions: [actionToValidate] }) + ).toThrowErrorMatchingInlineSnapshot( + `"[actions.0.type]: definition for this key is missing"` + ); + }); + }); }); diff --git a/x-pack/plugins/alerting/server/routes/legacy/create.ts b/x-pack/plugins/alerting/server/routes/legacy/create.ts index e7583033ae4ba..ca90a112a13f1 100644 --- a/x-pack/plugins/alerting/server/routes/legacy/create.ts +++ b/x-pack/plugins/alerting/server/routes/legacy/create.ts @@ -18,8 +18,9 @@ import { } from '../../types'; import { RuleTypeDisabledError } from '../../lib/errors/rule_type_disabled'; import { RouteOptions } from '..'; -import { countUsageOfPredefinedIds } from '../lib'; +import { countUsageOfPredefinedIds, rewriteActionsReq } from '../lib'; import { trackLegacyRouteUsage } from '../../lib/track_legacy_route_usage'; +import { rewriteActionsResLegacy } from '../lib/rewrite_actions'; export const bodySchema = schema.object({ name: schema.string(), @@ -34,7 +35,7 @@ export const bodySchema = schema.object({ }), actions: schema.arrayOf( schema.object({ - group: schema.string(), + group: schema.maybe(schema.string()), id: schema.string(), actionTypeId: schema.maybe(schema.string()), params: schema.recordOf(schema.string(), schema.any(), { defaultValue: {} }), @@ -65,6 +66,8 @@ export const createAlertRoute = ({ router, licenseState, usageCounter }: RouteOp return res.badRequest({ body: 'RouteHandlerContext is not registered for alerting' }); } const rulesClient = (await context.alerting).getRulesClient(); + const actionsClient = (await context.actions).getActionsClient(); + const alert = req.body; const params = req.params; const notifyWhen = alert?.notifyWhen ? (alert.notifyWhen as RuleNotifyWhenType) : null; @@ -79,11 +82,18 @@ export const createAlertRoute = ({ router, licenseState, usageCounter }: RouteOp try { const alertRes: SanitizedRule = await rulesClient.create({ - data: { ...alert, notifyWhen }, + data: { + ...alert, + actions: rewriteActionsReq(alert.actions, (connectorId: string) => + actionsClient.isSystemAction(connectorId) + ), + notifyWhen, + }, options: { id: params?.id }, }); + return res.ok({ - body: alertRes, + body: { ...alertRes, actions: rewriteActionsResLegacy(alertRes.actions) }, }); } catch (e) { if (e instanceof RuleTypeDisabledError) { diff --git a/x-pack/plugins/alerting/server/routes/legacy/find.test.ts b/x-pack/plugins/alerting/server/routes/legacy/find.test.ts index 026a4f81688e3..c639566223903 100644 --- a/x-pack/plugins/alerting/server/routes/legacy/find.test.ts +++ b/x-pack/plugins/alerting/server/routes/legacy/find.test.ts @@ -13,6 +13,8 @@ import { mockHandlerArguments } from '../_mock_handler_arguments'; import { rulesClientMock } from '../../rules_client.mock'; import { trackLegacyRouteUsage } from '../../lib/track_legacy_route_usage'; import { trackLegacyTerminology } from '../lib/track_legacy_terminology'; +import { RuleActionTypes } from '../../types'; +import { omit } from 'lodash'; const rulesClient = rulesClientMock.create(); @@ -33,6 +35,66 @@ beforeEach(() => { }); describe('findAlertRoute', () => { + const action = { + actionTypeId: 'test', + group: 'default', + id: '2', + params: { + foo: true, + }, + type: RuleActionTypes.DEFAULT, + }; + + const systemAction = { + actionTypeId: 'test-2', + id: 'system_action-id', + params: { + foo: true, + }, + type: RuleActionTypes.SYSTEM, + }; + + const rule = { + id: '1', + alertTypeId: '1', + schedule: { interval: '10s' }, + params: { + bar: true, + }, + createdAt: new Date(), + updatedAt: new Date(), + actions: [action], + consumer: 'bar', + name: 'abc', + tags: ['foo'], + enabled: true, + muteAll: false, + notifyWhen: 'onActionGroupChange' as const, + createdBy: '', + updatedBy: '', + apiKey: '', + apiKeyOwner: '', + throttle: '30s', + mutedInstanceIds: [], + executionStatus: { + status: 'unknown' as const, + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + }, + revision: 0, + }; + + const rulesClientResponse = { + page: 1, + perPage: 1, + total: 0, + data: [rule], + }; + + const findResponse = { + ...rulesClientResponse, + data: [{ ...rule, actions: [omit(action, 'type')] }], + }; + it('finds alerts with proper parameters', async () => { const licenseState = licenseStateMock.create(); const router = httpServiceMock.createRouter(); @@ -43,13 +105,7 @@ describe('findAlertRoute', () => { expect(config.path).toMatchInlineSnapshot(`"/api/alerts/_find"`); - const findResult = { - page: 1, - perPage: 1, - total: 0, - data: [], - }; - rulesClient.find.mockResolvedValueOnce(findResult); + rulesClient.find.mockResolvedValueOnce(rulesClientResponse); const [context, req, res] = mockHandlerArguments( { rulesClient }, @@ -63,16 +119,7 @@ describe('findAlertRoute', () => { ['ok'] ); - expect(await handler(context, req, res)).toMatchInlineSnapshot(` - Object { - "body": Object { - "data": Array [], - "page": 1, - "perPage": 1, - "total": 0, - }, - } - `); + await handler(context, req, res); expect(rulesClient.find).toHaveBeenCalledTimes(1); expect(rulesClient.find.mock.calls[0]).toMatchInlineSnapshot(` @@ -89,7 +136,7 @@ describe('findAlertRoute', () => { `); expect(res.ok).toHaveBeenCalledWith({ - body: findResult, + body: findResponse, }); }); @@ -101,12 +148,7 @@ describe('findAlertRoute', () => { const [, handler] = router.get.mock.calls[0]; - rulesClient.find.mockResolvedValueOnce({ - page: 1, - perPage: 1, - total: 0, - data: [], - }); + rulesClient.find.mockResolvedValueOnce(rulesClientResponse); const [context, req, res] = mockHandlerArguments( { rulesClient }, @@ -159,10 +201,13 @@ describe('findAlertRoute', () => { const mockUsageCounter = mockUsageCountersSetup.createUsageCounter('test'); findAlertRoute(router, licenseState, mockUsageCounter); + rulesClient.find.mockResolvedValueOnce(rulesClientResponse); + const [, handler] = router.get.mock.calls[0]; const [context, req, res] = mockHandlerArguments({ rulesClient }, { params: {}, query: {} }, [ 'ok', ]); + await handler(context, req, res); expect(trackLegacyRouteUsage).toHaveBeenCalledWith('find', mockUsageCounter); }); @@ -174,6 +219,8 @@ describe('findAlertRoute', () => { const mockUsageCounter = mockUsageCountersSetup.createUsageCounter('test'); findAlertRoute(router, licenseState, mockUsageCounter); + rulesClient.find.mockResolvedValueOnce(rulesClientResponse); + const [, handler] = router.get.mock.calls[0]; const [context, req, res] = mockHandlerArguments( { rulesClient }, @@ -187,7 +234,9 @@ describe('findAlertRoute', () => { }, ['ok'] ); + await handler(context, req, res); + expect(trackLegacyTerminology).toHaveBeenCalledTimes(1); expect((trackLegacyTerminology as jest.Mock).mock.calls[0][0]).toStrictEqual([ 'alertTypeId:2', @@ -203,6 +252,8 @@ describe('findAlertRoute', () => { const mockUsageCounter = mockUsageCountersSetup.createUsageCounter('test'); findAlertRoute(router, licenseState, mockUsageCounter); + rulesClient.find.mockResolvedValueOnce(rulesClientResponse); + const [, handler] = router.get.mock.calls[0]; const [context, req, res] = mockHandlerArguments( { rulesClient }, @@ -214,6 +265,7 @@ describe('findAlertRoute', () => { }, ['ok'] ); + await handler(context, req, res); expect(mockUsageCounter.incrementCounter).toHaveBeenCalledWith({ counterName: `legacyAlertingFieldsUsage`, @@ -221,4 +273,52 @@ describe('findAlertRoute', () => { incrementBy: 1, }); }); + + describe('actions', () => { + it('removes the type from the actions correctly before sending the response', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + const mockUsageCountersSetup = usageCountersServiceMock.createSetupContract(); + const mockUsageCounter = mockUsageCountersSetup.createUsageCounter('test'); + + findAlertRoute(router, licenseState, mockUsageCounter); + rulesClient.find.mockResolvedValueOnce({ + ...rulesClientResponse, + data: [{ ...rule, actions: [action, systemAction] }], + }); + + const [, handler] = router.get.mock.calls[0]; + const [context, req, res] = mockHandlerArguments( + { rulesClient }, + { + params: {}, + query: { + fields: ['foo', 'bar'], + }, + }, + ['ok'] + ); + + const routeRes = await handler(context, req, res); + + // @ts-expect-error: body exists + expect(routeRes.body.data[0].actions).toEqual([ + { + actionTypeId: 'test', + group: 'default', + id: '2', + params: { + foo: true, + }, + }, + { + actionTypeId: 'test-2', + id: 'system_action-id', + params: { + foo: true, + }, + }, + ]); + }); + }); }); diff --git a/x-pack/plugins/alerting/server/routes/legacy/find.ts b/x-pack/plugins/alerting/server/routes/legacy/find.ts index 9a33f5b2dc5fd..5e47b95b75827 100644 --- a/x-pack/plugins/alerting/server/routes/legacy/find.ts +++ b/x-pack/plugins/alerting/server/routes/legacy/find.ts @@ -16,6 +16,7 @@ import { renameKeys } from '../lib/rename_keys'; import { FindOptions } from '../../rules_client'; import { trackLegacyRouteUsage } from '../../lib/track_legacy_route_usage'; import { trackLegacyTerminology } from '../lib/track_legacy_terminology'; +import { rewriteActionsResLegacy } from '../lib/rewrite_actions'; // config definition const querySchema = schema.object({ @@ -98,8 +99,12 @@ export const findAlertRoute = ( } const findResult = await rulesClient.find({ options, excludeFromPublicApi: true }); + const findResultData = findResult.data.map((rule) => + Object.assign(rule, { actions: rewriteActionsResLegacy(rule.actions) }) + ); + return res.ok({ - body: findResult, + body: { ...findResult, data: findResultData }, }); }) ); diff --git a/x-pack/plugins/alerting/server/routes/legacy/get.test.ts b/x-pack/plugins/alerting/server/routes/legacy/get.test.ts index 403f7a5b42ac8..f108e20b4da7f 100644 --- a/x-pack/plugins/alerting/server/routes/legacy/get.test.ts +++ b/x-pack/plugins/alerting/server/routes/legacy/get.test.ts @@ -12,8 +12,9 @@ import { licenseStateMock } from '../../lib/license_state.mock'; import { verifyApiAccess } from '../../lib/license_api_access'; import { mockHandlerArguments } from '../_mock_handler_arguments'; import { rulesClientMock } from '../../rules_client.mock'; -import { Rule } from '../../../common'; +import { Rule, RuleActionTypes } from '../../../common'; import { trackLegacyRouteUsage } from '../../lib/track_legacy_route_usage'; +import { omit } from 'lodash'; const rulesClient = rulesClientMock.create(); jest.mock('../../lib/license_api_access', () => ({ @@ -29,7 +30,26 @@ beforeEach(() => { }); describe('getAlertRoute', () => { - const mockedAlert: Rule<{ + const action = { + actionTypeId: 'test', + group: 'default', + id: '2', + params: { + foo: true, + }, + type: RuleActionTypes.DEFAULT, + }; + + const systemAction = { + actionTypeId: 'test-2', + id: 'system_action-id', + params: { + foo: true, + }, + type: RuleActionTypes.SYSTEM, + }; + + const rulesClientResponse: Rule<{ bar: true; }> = { id: '1', @@ -40,16 +60,7 @@ describe('getAlertRoute', () => { }, createdAt: new Date(), updatedAt: new Date(), - actions: [ - { - group: 'default', - id: '2', - actionTypeId: 'test', - params: { - foo: true, - }, - }, - ], + actions: [action], consumer: 'bar', name: 'abc', tags: ['foo'], @@ -69,6 +80,11 @@ describe('getAlertRoute', () => { revision: 0, }; + const getResponse = { + ...rulesClientResponse, + actions: [omit(action, 'type')], + }; + it('gets an alert with proper parameters', async () => { const licenseState = licenseStateMock.create(); const router = httpServiceMock.createRouter(); @@ -78,7 +94,7 @@ describe('getAlertRoute', () => { expect(config.path).toMatchInlineSnapshot(`"/api/alerts/alert/{id}"`); - rulesClient.get.mockResolvedValueOnce(mockedAlert); + rulesClient.get.mockResolvedValueOnce(rulesClientResponse); const [context, req, res] = mockHandlerArguments( { rulesClient }, @@ -93,7 +109,7 @@ describe('getAlertRoute', () => { expect(rulesClient.get.mock.calls[0][0].id).toEqual('1'); expect(res.ok).toHaveBeenCalledWith({ - body: mockedAlert, + body: getResponse, }); }); @@ -105,7 +121,7 @@ describe('getAlertRoute', () => { const [, handler] = router.get.mock.calls[0]; - rulesClient.get.mockResolvedValueOnce(mockedAlert); + rulesClient.get.mockResolvedValueOnce(rulesClientResponse); const [context, req, res] = mockHandlerArguments( { rulesClient }, @@ -132,7 +148,7 @@ describe('getAlertRoute', () => { const [, handler] = router.get.mock.calls[0]; - rulesClient.get.mockResolvedValueOnce(mockedAlert); + rulesClient.get.mockResolvedValueOnce(rulesClientResponse); const [context, req, res] = mockHandlerArguments( { rulesClient }, @@ -153,12 +169,64 @@ describe('getAlertRoute', () => { const mockUsageCountersSetup = usageCountersServiceMock.createSetupContract(); const mockUsageCounter = mockUsageCountersSetup.createUsageCounter('test'); + rulesClient.get.mockResolvedValueOnce(rulesClientResponse); + getAlertRoute(router, licenseState, mockUsageCounter); + const [, handler] = router.get.mock.calls[0]; const [context, req, res] = mockHandlerArguments({ rulesClient }, { params: { id: '1' } }, [ 'ok', ]); + await handler(context, req, res); + expect(trackLegacyRouteUsage).toHaveBeenCalledWith('get', mockUsageCounter); }); + + describe('actions', () => { + it('removes the type from the actions correctly before sending the response', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + rulesClient.get.mockResolvedValueOnce({ + ...rulesClientResponse, + actions: [action, systemAction], + }); + + getAlertRoute(router, licenseState); + + const [_, handler] = router.get.mock.calls[0]; + + const [context, req, res] = mockHandlerArguments( + { rulesClient }, + { + params: { + id: '1', + }, + }, + ['ok'] + ); + + const routeRes = await handler(context, req, res); + + // @ts-expect-error: body exists + expect(routeRes.body.actions).toEqual([ + { + actionTypeId: 'test', + group: 'default', + id: '2', + params: { + foo: true, + }, + }, + { + actionTypeId: 'test-2', + id: 'system_action-id', + params: { + foo: true, + }, + }, + ]); + }); + }); }); diff --git a/x-pack/plugins/alerting/server/routes/legacy/get.ts b/x-pack/plugins/alerting/server/routes/legacy/get.ts index 62fdde5507148..0aba09dca4a5f 100644 --- a/x-pack/plugins/alerting/server/routes/legacy/get.ts +++ b/x-pack/plugins/alerting/server/routes/legacy/get.ts @@ -12,6 +12,7 @@ import { verifyApiAccess } from '../../lib/license_api_access'; import { LEGACY_BASE_ALERT_API_PATH } from '../../../common'; import type { AlertingRouter } from '../../types'; import { trackLegacyRouteUsage } from '../../lib/track_legacy_route_usage'; +import { rewriteActionsResLegacy } from '../lib/rewrite_actions'; const paramSchema = schema.object({ id: schema.string(), @@ -34,11 +35,15 @@ export const getAlertRoute = ( if (!context.alerting) { return res.badRequest({ body: 'RouteHandlerContext is not registered for alerting' }); } + trackLegacyRouteUsage('get', usageCounter); + const rulesClient = (await context.alerting).getRulesClient(); const { id } = req.params; + const rulesClientRes = await rulesClient.get({ id, excludeFromPublicApi: true }); + return res.ok({ - body: await rulesClient.get({ id, excludeFromPublicApi: true }), + body: { ...rulesClientRes, actions: rewriteActionsResLegacy(rulesClientRes.actions) }, }); }) ); diff --git a/x-pack/plugins/alerting/server/routes/legacy/update.test.ts b/x-pack/plugins/alerting/server/routes/legacy/update.test.ts index 756e751e5c6e4..3d31d5cf009f0 100644 --- a/x-pack/plugins/alerting/server/routes/legacy/update.test.ts +++ b/x-pack/plugins/alerting/server/routes/legacy/update.test.ts @@ -13,8 +13,10 @@ import { verifyApiAccess } from '../../lib/license_api_access'; import { mockHandlerArguments } from '../_mock_handler_arguments'; import { rulesClientMock } from '../../rules_client.mock'; import { RuleTypeDisabledError } from '../../lib/errors/rule_type_disabled'; -import { RuleNotifyWhen } from '../../../common'; +import { RuleActionTypes, RuleNotifyWhen, RuleSystemAction } from '../../../common'; import { trackLegacyRouteUsage } from '../../lib/track_legacy_route_usage'; +import { actionsClientMock } from '@kbn/actions-plugin/server/mocks'; +import { omit } from 'lodash'; const rulesClient = rulesClientMock.create(); jest.mock('../../lib/license_api_access', () => ({ @@ -30,6 +32,25 @@ beforeEach(() => { }); describe('updateAlertRoute', () => { + const action = { + group: 'default', + id: '2', + actionTypeId: 'test', + params: { + baz: true, + }, + type: RuleActionTypes.DEFAULT, + }; + + const systemAction: RuleSystemAction = { + actionTypeId: 'test-2', + id: 'system_action-id', + params: { + foo: true, + }, + type: RuleActionTypes.SYSTEM, + }; + const mockedResponse = { id: '1', alertTypeId: '1', @@ -40,6 +61,32 @@ describe('updateAlertRoute', () => { }, createdAt: new Date(), updatedAt: new Date(), + actions: [action], + notifyWhen: RuleNotifyWhen.CHANGE, + }; + + const updateRequest = { + throttle: null, + name: 'abc', + tags: ['bar'], + schedule: { interval: '12s' }, + params: { + otherField: false, + }, + actions: [ + { + group: 'default', + id: '2', + params: { + baz: true, + }, + }, + ], + notifyWhen: 'onActionGroupChange', + }; + + const updateResult = { + ...mockedResponse, actions: [ { group: 'default', @@ -50,7 +97,6 @@ describe('updateAlertRoute', () => { }, }, ], - notifyWhen: RuleNotifyWhen.CHANGE, }; it('updates an alert with proper parameters', async () => { @@ -71,30 +117,12 @@ describe('updateAlertRoute', () => { params: { id: '1', }, - body: { - throttle: null, - name: 'abc', - tags: ['bar'], - schedule: { interval: '12s' }, - params: { - otherField: false, - }, - actions: [ - { - group: 'default', - id: '2', - params: { - baz: true, - }, - }, - ], - notifyWhen: 'onActionGroupChange', - }, + body: updateRequest, }, ['ok'] ); - expect(await handler(context, req, res)).toEqual({ body: mockedResponse }); + expect(await handler(context, req, res)).toEqual({ body: updateResult }); expect(rulesClient.update).toHaveBeenCalledTimes(1); expect(rulesClient.update.mock.calls[0]).toMatchInlineSnapshot(` @@ -108,6 +136,7 @@ describe('updateAlertRoute', () => { "params": Object { "baz": true, }, + "type": "default", }, ], "name": "abc", @@ -247,12 +276,179 @@ describe('updateAlertRoute', () => { const mockUsageCountersSetup = usageCountersServiceMock.createSetupContract(); const mockUsageCounter = mockUsageCountersSetup.createUsageCounter('test'); + rulesClient.update.mockResolvedValueOnce(mockedResponse); + updateAlertRoute(router, licenseState, mockUsageCounter); + const [, handler] = router.put.mock.calls[0]; const [context, req, res] = mockHandlerArguments({ rulesClient }, { params: {}, body: {} }, [ 'ok', ]); + await handler(context, req, res); expect(trackLegacyRouteUsage).toHaveBeenCalledWith('update', mockUsageCounter); }); + + describe('actions', () => { + it('adds the type of the actions correctly before passing the request to the rules client', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + const actionsClient = actionsClientMock.create(); + actionsClient.isSystemAction.mockImplementation((id: string) => id === 'system_action-id'); + + updateAlertRoute(router, licenseState); + + const [_, handler] = router.put.mock.calls[0]; + rulesClient.update.mockResolvedValueOnce({ + ...mockedResponse, + actions: [action, systemAction], + }); + + const [context, req, res] = mockHandlerArguments( + { rulesClient, actionsClient }, + { + params: { + id: '1', + }, + body: { ...updateRequest, actions: [omit(action, 'type'), omit(systemAction, 'type')] }, + }, + ['ok'] + ); + + await handler(context, req, res); + + expect(rulesClient.update.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "data": Object { + "actions": Array [ + Object { + "group": "default", + "id": "2", + "params": Object { + "baz": true, + }, + "type": "default", + }, + Object { + "id": "system_action-id", + "params": Object { + "foo": true, + }, + "type": "system", + }, + ], + "name": "abc", + "notifyWhen": "onActionGroupChange", + "params": Object { + "otherField": false, + }, + "schedule": Object { + "interval": "12s", + }, + "tags": Array [ + "bar", + ], + "throttle": null, + }, + "id": "1", + }, + ] + `); + }); + + it('removes the type from the actions correctly before sending the response', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + const actionsClient = actionsClientMock.create(); + actionsClient.isSystemAction.mockImplementation((id: string) => id === 'system_action-id'); + + updateAlertRoute(router, licenseState); + + const [_, handler] = router.put.mock.calls[0]; + rulesClient.update.mockResolvedValueOnce({ + ...mockedResponse, + actions: [action, systemAction], + }); + + const [context, req, res] = mockHandlerArguments( + { rulesClient, actionsClient }, + { + params: { + id: '1', + }, + body: { ...updateRequest, actions: [omit(action, 'type'), omit(systemAction, 'type')] }, + }, + ['ok'] + ); + + const routeRes = await handler(context, req, res); + + // @ts-expect-error: body exists + expect(routeRes.body.actions).toEqual([ + { + actionTypeId: 'test', + group: 'default', + id: '2', + params: { + baz: true, + }, + }, + { + actionTypeId: 'test-2', + id: 'system_action-id', + params: { + foo: true, + }, + }, + ]); + }); + + it('does not fails if the system action does not contain a group', async () => { + const actionToValidate = { + actionTypeId: 'test-2', + id: 'system_action-id', + params: { + foo: true, + }, + }; + + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + updateAlertRoute(router, licenseState); + + const [config, _] = router.put.mock.calls[0]; + + expect(() => + // @ts-expect-error: body exists + config.validate.body.validate({ ...updateRequest, actions: [actionToValidate] }) + ).not.toThrow(); + }); + + it('fails if the action contains a type in the request', async () => { + const actionToValidate = { + group: 'default', + id: '2', + params: { + foo: true, + }, + type: RuleActionTypes.DEFAULT, + }; + + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + updateAlertRoute(router, licenseState); + + const [config, _] = router.put.mock.calls[0]; + + expect(() => + // @ts-expect-error: body exists + config.validate.body.validate({ ...updateRequest, actions: [actionToValidate] }) + ).toThrowErrorMatchingInlineSnapshot( + `"[actions.0.type]: definition for this key is missing"` + ); + }); + }); }); diff --git a/x-pack/plugins/alerting/server/routes/legacy/update.ts b/x-pack/plugins/alerting/server/routes/legacy/update.ts index 07bde524076c1..f83e8f657400a 100644 --- a/x-pack/plugins/alerting/server/routes/legacy/update.ts +++ b/x-pack/plugins/alerting/server/routes/legacy/update.ts @@ -19,6 +19,8 @@ import { LEGACY_BASE_ALERT_API_PATH, validateNotifyWhenType, } from '../../../common'; +import { rewriteActionsReq } from '../lib'; +import { rewriteActionsResLegacy } from '../lib/rewrite_actions'; const paramSchema = schema.object({ id: schema.string(), @@ -34,7 +36,7 @@ const bodySchema = schema.object({ params: schema.recordOf(schema.string(), schema.any(), { defaultValue: {} }), actions: schema.arrayOf( schema.object({ - group: schema.string(), + group: schema.maybe(schema.string()), id: schema.string(), params: schema.recordOf(schema.string(), schema.any(), { defaultValue: {} }), actionTypeId: schema.maybe(schema.string()), @@ -65,6 +67,8 @@ export const updateAlertRoute = ( } trackLegacyRouteUsage('update', usageCounter); const rulesClient = (await context.alerting).getRulesClient(); + const actionsClient = (await context.actions).getActionsClient(); + const { id } = req.params; const { name, actions, params, schedule, tags, throttle, notifyWhen } = req.body; try { @@ -72,7 +76,9 @@ export const updateAlertRoute = ( id, data: { name, - actions, + actions: rewriteActionsReq(actions, (connectorId: string) => + actionsClient.isSystemAction(connectorId) + ), params, schedule, tags, @@ -81,7 +87,7 @@ export const updateAlertRoute = ( }, }); return res.ok({ - body: alertRes, + body: { ...alertRes, actions: rewriteActionsResLegacy(alertRes.actions) }, }); } catch (e) { if (e instanceof RuleTypeDisabledError) { diff --git a/x-pack/plugins/alerting/server/routes/lib/actions_schema.ts b/x-pack/plugins/alerting/server/routes/lib/actions_schema.ts index 9d6f89e070c3a..f5b9e1d2451d8 100644 --- a/x-pack/plugins/alerting/server/routes/lib/actions_schema.ts +++ b/x-pack/plugins/alerting/server/routes/lib/actions_schema.ts @@ -12,7 +12,7 @@ import { validateHours } from './validate_hours'; export const actionsSchema = schema.arrayOf( schema.object({ - group: schema.string(), + group: schema.maybe(schema.string()), id: schema.string(), params: schema.recordOf(schema.string(), schema.any(), { defaultValue: {} }), frequency: schema.maybe( diff --git a/x-pack/plugins/alerting/server/routes/lib/rewrite_actions.test.ts b/x-pack/plugins/alerting/server/routes/lib/rewrite_actions.test.ts index 61dc9282bbfa1..df56554d77ef7 100644 --- a/x-pack/plugins/alerting/server/routes/lib/rewrite_actions.test.ts +++ b/x-pack/plugins/alerting/server/routes/lib/rewrite_actions.test.ts @@ -5,11 +5,15 @@ * 2.0. */ -import { rewriteActionsReq, rewriteActionsRes } from './rewrite_actions'; +import { omit } from 'lodash'; +import { RuleActionTypes } from '../../../common'; +import { rewriteActionsReq, rewriteActionsRes, rewriteActionsResLegacy } from './rewrite_actions'; describe('rewrite Actions', () => { + const isSystemAction = (id: string) => id === 'system-action-id'; + describe('rewriteActionsRes', () => { - it('rewrites the actions response correctly', () => { + it('rewrites a default action response correctly', () => { expect( rewriteActionsRes([ { @@ -38,6 +42,7 @@ describe('rewrite Actions', () => { }, }, }, + type: RuleActionTypes.DEFAULT, }, ]) ).toEqual([ @@ -59,11 +64,68 @@ describe('rewrite Actions', () => { }, ]); }); + + it('rewrites a system action response correctly', () => { + expect( + rewriteActionsRes([ + { + uuid: '111', + id: 'system-action-id', + params: { foo: 'bar' }, + type: RuleActionTypes.SYSTEM, + actionTypeId: '.test', + }, + ]) + ).toEqual([ + { + uuid: '111', + id: 'system-action-id', + params: { foo: 'bar' }, + connector_type_id: '.test', + }, + ]); + }); + + it('returns an empty array if the actions are undefined', () => { + expect(rewriteActionsRes()).toEqual([]); + }); }); describe('rewriteActionsReq', () => { - expect( - rewriteActionsReq([ + it('rewrites a default action request correctly', () => { + expect( + rewriteActionsReq( + [ + { + uuid: '111', + group: 'default', + id: '1', + params: { foo: 'bar' }, + frequency: { + summary: true, + notify_when: 'onThrottleInterval', + throttle: '1h', + }, + alerts_filter: { + query: { + kql: 'test:1s', + dsl: '{test:1}', + filters: [], + }, + timeframe: { + days: [1, 2, 3], + timezone: 'UTC', + hours: { + start: '00:00', + end: '15:00', + }, + }, + }, + }, + ], + isSystemAction + ) + ).toEqual([ { uuid: '111', group: 'default', @@ -71,10 +133,10 @@ describe('rewrite Actions', () => { params: { foo: 'bar' }, frequency: { summary: true, - notify_when: 'onThrottleInterval', + notifyWhen: 'onThrottleInterval', throttle: '1h', }, - alerts_filter: { + alertsFilter: { query: { kql: 'test:1s', dsl: '{test:1}', @@ -89,35 +151,63 @@ describe('rewrite Actions', () => { }, }, }, + type: RuleActionTypes.DEFAULT, }, - ]) - ).toEqual([ - { - uuid: '111', - group: 'default', - id: '1', - params: { foo: 'bar' }, - frequency: { - summary: true, - notifyWhen: 'onThrottleInterval', - throttle: '1h', - }, - alertsFilter: { - query: { - kql: 'test:1s', - dsl: '{test:1}', - filters: [], - }, - timeframe: { - days: [1, 2, 3], - timezone: 'UTC', - hours: { - start: '00:00', - end: '15:00', + ]); + }); + + it('rewrites a system action request correctly', () => { + expect( + rewriteActionsReq( + [ + { + uuid: '111', + id: 'system-action-id', + params: { foo: 'bar' }, }, - }, + ], + isSystemAction + ) + ).toEqual([ + { + uuid: '111', + id: 'system-action-id', + params: { foo: 'bar' }, + type: RuleActionTypes.SYSTEM, }, + ]); + }); + + it('returns an empty array if the actions are empty', () => { + expect(rewriteActionsReq([], isSystemAction)).toEqual([]); + }); + }); + + describe('rewriteActionsResLegacy', () => { + const action = { + actionTypeId: 'test', + group: 'default', + id: '2', + params: { + foo: true, + }, + type: RuleActionTypes.DEFAULT, + }; + + const systemAction = { + actionTypeId: 'test-2', + id: 'system_action-id', + params: { + foo: true, }, - ]); + type: RuleActionTypes.SYSTEM, + }; + + it('removes the type from the actions', () => { + expect(rewriteActionsResLegacy([action, systemAction])).toEqual([ + omit(action, 'type'), + omit(systemAction, 'type'), + ]); + }); }); }); diff --git a/x-pack/plugins/alerting/server/routes/lib/rewrite_actions.ts b/x-pack/plugins/alerting/server/routes/lib/rewrite_actions.ts index 2c2b0853d181b..c43a4f33af49d 100644 --- a/x-pack/plugins/alerting/server/routes/lib/rewrite_actions.ts +++ b/x-pack/plugins/alerting/server/routes/lib/rewrite_actions.ts @@ -6,18 +6,38 @@ */ import { TypeOf } from '@kbn/config-schema/src/types/object_type'; import { omit } from 'lodash'; +import { AsApiContract } from '.'; +import { RuleActionResponse, RuleActionTypes, RuleDefaultAction } from '../../../common'; +import { isSystemAction } from '../../../common/system_actions/is_system_action'; import { NormalizedAlertAction } from '../../rules_client'; import { RuleAction } from '../../types'; import { actionsSchema } from './actions_schema'; export const rewriteActionsReq = ( - actions?: TypeOf + actions: TypeOf, + isSystemConnector: (connectorId: string) => boolean ): NormalizedAlertAction[] => { - if (!actions) return []; + if (!actions || actions.length === 0) { + return []; + } + + return actions.map((action) => { + if (isSystemConnector(action.id)) { + return { + id: action.id, + params: action.params, + ...(action.uuid ? { uuid: action.uuid } : {}), + type: RuleActionTypes.SYSTEM, + }; + } + + const { frequency, alerts_filter: alertsFilter, group } = action; - return actions.map(({ frequency, alerts_filter: alertsFilter, ...action }) => { return { - ...action, + id: action.id, + params: action.params, + ...(action.uuid ? { uuid: action.uuid } : {}), + group: group ?? 'default', ...(frequency ? { frequency: { @@ -27,24 +47,49 @@ export const rewriteActionsReq = ( } : {}), ...(alertsFilter ? { alertsFilter } : {}), + type: RuleActionTypes.DEFAULT, }; }); }; -export const rewriteActionsRes = (actions?: RuleAction[]) => { - const rewriteFrequency = ({ notifyWhen, ...rest }: NonNullable) => ({ +export const rewriteActionsRes = ( + actions?: RuleAction[] +): Array> => { + const rewriteFrequency = ({ + notifyWhen, + ...rest + }: NonNullable) => ({ ...rest, notify_when: notifyWhen, }); + if (!actions) return []; - return actions.map(({ actionTypeId, frequency, alertsFilter, ...action }) => ({ - ...action, - connector_type_id: actionTypeId, - ...(frequency ? { frequency: rewriteFrequency(frequency) } : {}), - ...(alertsFilter - ? { - alerts_filter: alertsFilter, - } - : {}), - })); + + return actions.map((action) => { + if (isSystemAction(action)) { + const { actionTypeId, type, ...restAction } = action; + return { ...restAction, connector_type_id: actionTypeId }; + } + + const { actionTypeId, frequency, alertsFilter, type, ...restAction } = action; + + return { + ...restAction, + connector_type_id: actionTypeId, + ...(frequency ? { frequency: rewriteFrequency(frequency) } : {}), + ...(alertsFilter + ? { + alerts_filter: alertsFilter, + } + : {}), + }; + }); +}; + +export const rewriteActionsResLegacy = ( + actions?: T[] +): Array> => { + if (!actions) return []; + + return actions.map(({ type, ...restAction }) => restAction); }; diff --git a/x-pack/plugins/alerting/server/routes/lib/rewrite_rule.test.ts b/x-pack/plugins/alerting/server/routes/lib/rewrite_rule.test.ts index 7a348e583ac6c..d453ab7e6cd70 100644 --- a/x-pack/plugins/alerting/server/routes/lib/rewrite_rule.test.ts +++ b/x-pack/plugins/alerting/server/routes/lib/rewrite_rule.test.ts @@ -5,11 +5,39 @@ * 2.0. */ import { rewriteRule } from './rewrite_rule'; -import { RuleTypeParams, SanitizedRule } from '../../types'; +import { + RuleActionTypes, + RuleDefaultAction, + RuleSystemAction, + RuleTypeParams, + SanitizedRule, +} from '../../types'; import { isPlainObject } from 'lodash'; const DATE_2020 = new Date('1/1/2020'); +const defaultAction: RuleDefaultAction = { + group: 'default', + id: 'aaa', + actionTypeId: 'bbb', + params: {}, + frequency: { + summary: false, + notifyWhen: 'onThrottleInterval', + throttle: '1m', + }, + alertsFilter: { query: { kql: 'test:1', dsl: '{}', filters: [] } }, + type: RuleActionTypes.DEFAULT, +}; + +const systemAction: RuleSystemAction = { + id: 'system-action', + uuid: '123', + actionTypeId: 'bbb', + params: {}, + type: RuleActionTypes.SYSTEM, +}; + const sampleRule: SanitizedRule & { activeSnoozes?: string[] } = { id: 'aaaa', name: 'Sample Rule', @@ -32,20 +60,7 @@ const sampleRule: SanitizedRule & { activeSnoozes?: string[] } = lastExecutionDate: DATE_2020, lastDuration: 1000, }, - actions: [ - { - group: 'default', - id: 'aaa', - actionTypeId: 'bbb', - params: {}, - frequency: { - summary: false, - notifyWhen: 'onThrottleInterval', - throttle: '1m', - }, - alertsFilter: { query: { kql: 'test:1', dsl: '{}', filters: [] } }, - }, - ], + actions: [defaultAction, systemAction], scheduledTaskId: 'xyz456', snoozeSchedule: [], isSnoozedUntil: null, @@ -76,15 +91,36 @@ describe('rewriteRule', () => { } } }); + it('should rewrite actions correctly', () => { - const rewritten = rewriteRule(sampleRule); - for (const action of rewritten.actions) { - expect(Object.keys(action)).toEqual( - expect.arrayContaining(['group', 'id', 'connector_type_id', 'params', 'frequency']) - ); - expect(Object.keys(action.frequency!)).toEqual( - expect.arrayContaining(['summary', 'notify_when', 'throttle']) - ); - } + const res = rewriteRule(sampleRule); + expect(res.actions).toMatchInlineSnapshot(` + Array [ + Object { + "alerts_filter": Object { + "query": Object { + "dsl": "{}", + "filters": Array [], + "kql": "test:1", + }, + }, + "connector_type_id": "bbb", + "frequency": Object { + "notify_when": "onThrottleInterval", + "summary": false, + "throttle": "1m", + }, + "group": "default", + "id": "aaa", + "params": Object {}, + }, + Object { + "connector_type_id": "bbb", + "id": "system-action", + "params": Object {}, + "uuid": "123", + }, + ] + `); }); }); diff --git a/x-pack/plugins/alerting/server/routes/lib/rewrite_rule.ts b/x-pack/plugins/alerting/server/routes/lib/rewrite_rule.ts index 953211a5ef4f7..a7d4be2f0a2e5 100644 --- a/x-pack/plugins/alerting/server/routes/lib/rewrite_rule.ts +++ b/x-pack/plugins/alerting/server/routes/lib/rewrite_rule.ts @@ -5,8 +5,10 @@ * 2.0. */ import { omit } from 'lodash'; +import { isSystemAction } from '../../../common/system_actions/is_system_action'; -import { RuleTypeParams, SanitizedRule, RuleLastRun } from '../../types'; +import { RuleTypeParams, SanitizedRule, RuleLastRun, SanitizedRuleResponse } from '../../types'; +import { AsApiContract } from './rewrite_request_case'; export const rewriteRuleLastRun = (lastRun: RuleLastRun) => { const { outcomeMsg, outcomeOrder, alertsCount, ...rest } = lastRun; @@ -38,7 +40,9 @@ export const rewriteRule = ({ lastRun, nextRun, ...rest -}: SanitizedRule & { activeSnoozes?: string[] }) => ({ +}: SanitizedRule & { + activeSnoozes?: string[]; +}): AsApiContract> => ({ ...rest, rule_type_id: alertTypeId, created_by: createdBy, @@ -58,23 +62,32 @@ export const rewriteRule = ({ last_execution_date: executionStatus.lastExecutionDate, last_duration: executionStatus.lastDuration, }, - actions: actions.map(({ group, id, actionTypeId, params, frequency, uuid, alertsFilter }) => ({ - group, - id, - params, - connector_type_id: actionTypeId, - ...(frequency - ? { - frequency: { - summary: frequency.summary, - notify_when: frequency.notifyWhen, - throttle: frequency.throttle, - }, - } - : {}), - ...(uuid && { uuid }), - ...(alertsFilter && { alerts_filter: alertsFilter }), - })), + actions: actions.map((action) => { + if (isSystemAction(action)) { + const { actionTypeId, type, ...restSystemAction } = action; + return { ...restSystemAction, connector_type_id: action.actionTypeId }; + } + + const { group, id, actionTypeId, params, frequency, uuid, alertsFilter } = action; + + return { + group, + id, + params, + connector_type_id: actionTypeId, + ...(frequency + ? { + frequency: { + summary: frequency.summary, + notify_when: frequency.notifyWhen, + throttle: frequency.throttle, + }, + } + : {}), + ...(uuid && { uuid }), + ...(alertsFilter && { alerts_filter: alertsFilter }), + }; + }), ...(lastRun ? { last_run: rewriteRuleLastRun(lastRun) } : {}), ...(nextRun ? { next_run: nextRun } : {}), ...(apiKeyCreatedByUser !== undefined ? { api_key_created_by_user: apiKeyCreatedByUser } : {}), diff --git a/x-pack/plugins/alerting/server/routes/resolve_rule.test.ts b/x-pack/plugins/alerting/server/routes/resolve_rule.test.ts index 273a7ea7fb8aa..bf7046970ee61 100644 --- a/x-pack/plugins/alerting/server/routes/resolve_rule.test.ts +++ b/x-pack/plugins/alerting/server/routes/resolve_rule.test.ts @@ -12,7 +12,13 @@ import { licenseStateMock } from '../lib/license_state.mock'; import { verifyApiAccess } from '../lib/license_api_access'; import { mockHandlerArguments } from './_mock_handler_arguments'; import { rulesClientMock } from '../rules_client.mock'; -import { ResolvedSanitizedRule } from '../types'; +import { + ResolvedSanitizedRule, + ResolvedSanitizedRuleResponse, + RuleActionTypes, + RuleDefaultAction, + RuleSystemAction, +} from '../types'; import { AsApiContract } from './lib'; const rulesClient = rulesClientMock.create(); @@ -25,6 +31,27 @@ beforeEach(() => { }); describe('resolveRuleRoute', () => { + const action: RuleDefaultAction = { + group: 'default', + id: '2', + actionTypeId: 'test', + params: { + foo: true, + }, + uuid: '123-456', + type: RuleActionTypes.DEFAULT, + }; + + const systemAction: RuleSystemAction = { + actionTypeId: 'test-2', + id: 'system_action-id', + params: { + foo: true, + }, + uuid: '123-456', + type: RuleActionTypes.SYSTEM, + }; + const mockedRule: ResolvedSanitizedRule<{ bar: boolean; }> = { @@ -36,17 +63,7 @@ describe('resolveRuleRoute', () => { }, createdAt: new Date(), updatedAt: new Date(), - actions: [ - { - group: 'default', - id: '2', - actionTypeId: 'test', - params: { - foo: true, - }, - uuid: '123-456', - }, - ], + actions: [action], consumer: 'bar', name: 'abc', tags: ['foo'], @@ -67,7 +84,7 @@ describe('resolveRuleRoute', () => { revision: 0, }; - const resolveResult: AsApiContract> = { + const resolveResult: AsApiContract> = { ...pick( mockedRule, 'consumer', @@ -96,7 +113,7 @@ describe('resolveRuleRoute', () => { }, actions: [ { - group: mockedRule.actions[0].group, + group: action.group, id: mockedRule.actions[0].id, params: mockedRule.actions[0].params, connector_type_id: mockedRule.actions[0].actionTypeId, @@ -183,4 +200,49 @@ describe('resolveRuleRoute', () => { expect(verifyApiAccess).toHaveBeenCalledWith(licenseState); }); + + describe('actions', () => { + it('removes the type from the actions correctly before sending the response', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + resolveRuleRoute(router, licenseState); + + const [_, handler] = router.get.mock.calls[0]; + rulesClient.resolve.mockResolvedValueOnce({ ...mockedRule, actions: [action, systemAction] }); + + const [context, req, res] = mockHandlerArguments( + { rulesClient }, + { + params: { + id: '1', + }, + }, + ['ok'] + ); + + const routeRes = await handler(context, req, res); + + // @ts-expect-error: body exists + expect(routeRes.body.actions).toEqual([ + { + connector_type_id: 'test', + group: 'default', + id: '2', + params: { + foo: true, + }, + uuid: '123-456', + }, + { + connector_type_id: 'test-2', + id: 'system_action-id', + params: { + foo: true, + }, + uuid: '123-456', + }, + ]); + }); + }); }); diff --git a/x-pack/plugins/alerting/server/routes/resolve_rule.ts b/x-pack/plugins/alerting/server/routes/resolve_rule.ts index e42dd6795f14a..7cbe6d5e47e1f 100644 --- a/x-pack/plugins/alerting/server/routes/resolve_rule.ts +++ b/x-pack/plugins/alerting/server/routes/resolve_rule.ts @@ -11,22 +11,23 @@ import { IRouter } from '@kbn/core/server'; import { ILicenseState } from '../lib'; import { verifyAccessAndContext, - RewriteResponseCase, rewriteRuleLastRun, rewriteActionsRes, + AsApiContract, } from './lib'; import { RuleTypeParams, AlertingRequestHandlerContext, INTERNAL_BASE_ALERTING_API_PATH, ResolvedSanitizedRule, + ResolvedSanitizedRuleResponse, } from '../types'; const paramSchema = schema.object({ id: schema.string(), }); -const rewriteBodyRes: RewriteResponseCase> = ({ +const rewriteBodyRes = ({ alertTypeId, createdBy, updatedBy, @@ -43,7 +44,9 @@ const rewriteBodyRes: RewriteResponseCase> lastRun, nextRun, ...rest -}) => ({ +}: ResolvedSanitizedRule): AsApiContract< + ResolvedSanitizedRuleResponse +> => ({ ...rest, rule_type_id: alertTypeId, created_by: createdBy, diff --git a/x-pack/plugins/alerting/server/routes/rule/apis/bulk_edit/bulk_edit_rules_route.test.ts b/x-pack/plugins/alerting/server/routes/rule/apis/bulk_edit/bulk_edit_rules_route.test.ts index 1b1ed454c5207..bf27692bece81 100644 --- a/x-pack/plugins/alerting/server/routes/rule/apis/bulk_edit/bulk_edit_rules_route.test.ts +++ b/x-pack/plugins/alerting/server/routes/rule/apis/bulk_edit/bulk_edit_rules_route.test.ts @@ -13,7 +13,14 @@ import { verifyApiAccess } from '../../../../lib/license_api_access'; import { RuleTypeDisabledError } from '../../../../lib/errors/rule_type_disabled'; import { mockHandlerArguments } from '../../../_mock_handler_arguments'; import { rulesClientMock } from '../../../../rules_client.mock'; -import { SanitizedRule } from '../../../../types'; +import { + RuleActionTypes, + RuleDefaultAction, + RuleSystemAction, + SanitizedRule, +} from '../../../../types'; +import { actionsClientMock } from '@kbn/actions-plugin/server/mocks'; +import { omit } from 'lodash'; const rulesClient = rulesClientMock.create(); jest.mock('../../../../lib/license_api_access', () => ({ @@ -42,6 +49,7 @@ describe('bulkEditRulesRoute', () => { foo: true, }, uuid: '123-456', + type: RuleActionTypes.DEFAULT, }, ], consumer: 'bar', @@ -189,4 +197,187 @@ describe('bulkEditRulesRoute', () => { expect(res.forbidden).toHaveBeenCalledWith({ body: { message: 'Fail' } }); }); + + describe('actions', () => { + const action: RuleDefaultAction = { + actionTypeId: 'test', + group: 'default', + id: '2', + params: { + foo: true, + }, + uuid: '123-456', + type: RuleActionTypes.DEFAULT, + }; + + const systemAction: RuleSystemAction = { + actionTypeId: 'test-2', + id: 'system_action-id', + params: { + foo: true, + }, + uuid: '123-456', + type: RuleActionTypes.SYSTEM, + }; + + const mockedActionAlerts: Array> = [ + { ...mockedAlert, actions: [action, systemAction] }, + ]; + + const bulkEditActionsRequest = { + filter: '', + operations: [ + { + operation: 'add', + field: 'actions', + value: [omit(action, 'type'), omit(systemAction, 'type')], + }, + ], + }; + + const bulkEditActionsResult = { rules: mockedActionAlerts, errors: [], total: 1, skipped: [] }; + + it('adds the type of the actions correctly before passing the request to the rules client', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + const actionsClient = actionsClientMock.create(); + actionsClient.isSystemAction.mockImplementation((id: string) => id === 'system_action-id'); + + bulkEditInternalRulesRoute(router, licenseState); + + const [_, handler] = router.post.mock.calls[0]; + + rulesClient.bulkEdit.mockResolvedValueOnce(bulkEditActionsResult); + + const [context, req, res] = mockHandlerArguments( + { rulesClient, actionsClient }, + { + body: bulkEditActionsRequest, + }, + ['ok'] + ); + + await handler(context, req, res); + + expect(rulesClient.bulkEdit.mock.calls[0][0]).toMatchInlineSnapshot(` + Object { + "filter": "", + "ids": undefined, + "operations": Array [ + Object { + "field": "actions", + "operation": "add", + "value": Array [ + Object { + "frequency": undefined, + "group": "default", + "id": "2", + "params": Object { + "foo": true, + }, + "type": "default", + "uuid": "123-456", + }, + Object { + "id": "system_action-id", + "params": Object { + "foo": true, + }, + "type": "system", + "uuid": "123-456", + }, + ], + }, + ], + } + `); + }); + + it('removes the type from the actions correctly before sending the response', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + const actionsClient = actionsClientMock.create(); + actionsClient.isSystemAction.mockImplementation((id: string) => id === 'system_action-id'); + + bulkEditInternalRulesRoute(router, licenseState); + + const [_, handler] = router.post.mock.calls[0]; + + rulesClient.bulkEdit.mockResolvedValueOnce(bulkEditActionsResult); + + const [context, req, res] = mockHandlerArguments( + { rulesClient, actionsClient }, + { + body: bulkEditActionsRequest, + }, + ['ok'] + ); + + const routeRes = await handler(context, req, res); + + // @ts-expect-error: body exists + expect(routeRes.body.rules[0].actions).toEqual([ + { + connector_type_id: 'test', + group: 'default', + id: '2', + params: { + foo: true, + }, + uuid: '123-456', + }, + { + connector_type_id: 'test-2', + id: 'system_action-id', + params: { + foo: true, + }, + uuid: '123-456', + }, + ]); + }); + + it('fails if the action contains a type in the request', async () => { + const actionToValidate = { + group: 'default', + id: '2', + params: { + foo: true, + }, + uuid: '123-456', + type: RuleActionTypes.DEFAULT, + }; + + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + bulkEditInternalRulesRoute(router, licenseState); + + const [config, _] = router.post.mock.calls[0]; + + expect(() => + // @ts-expect-error: body exists + config.validate.body.validate({ + ...bulkEditActionsRequest, + operations: [ + { + operation: 'add', + field: 'actions', + value: [actionToValidate], + }, + ], + }) + ).toThrowErrorMatchingInlineSnapshot(` + "[operations.0]: types that failed validation: + - [operations.0.0.field]: expected value to equal [tags] + - [operations.0.1.value.0.type]: definition for this key is missing + - [operations.0.2.operation]: expected value to equal [set] + - [operations.0.3.operation]: expected value to equal [set] + - [operations.0.4.operation]: expected value to equal [set] + - [operations.0.5.operation]: expected value to equal [set] + - [operations.0.6.operation]: expected value to equal [delete] + - [operations.0.7.operation]: expected value to equal [set]" + `); + }); + }); }); diff --git a/x-pack/plugins/alerting/server/routes/rule/apis/bulk_edit/bulk_edit_rules_route.ts b/x-pack/plugins/alerting/server/routes/rule/apis/bulk_edit/bulk_edit_rules_route.ts index ae39ceba1ceb3..9574c6c475ae4 100644 --- a/x-pack/plugins/alerting/server/routes/rule/apis/bulk_edit/bulk_edit_rules_route.ts +++ b/x-pack/plugins/alerting/server/routes/rule/apis/bulk_edit/bulk_edit_rules_route.ts @@ -20,6 +20,7 @@ import { Rule } from '../../../../application/rule/types'; import type { RuleParamsV1 } from '../../../../../common/routes/rule/response'; import { transformRuleToRuleResponseV1 } from '../../transforms'; +import { transformOperationsV1 } from './transforms'; interface BuildBulkEditRulesRouteParams { licenseState: ILicenseState; @@ -39,15 +40,19 @@ const buildBulkEditRulesRoute = ({ licenseState, path, router }: BuildBulkEditRu router.handleLegacyErrors( verifyAccessAndContext(licenseState, async function (context, req, res) { const rulesClient = (await context.alerting).getRulesClient(); - const bulkEditData: BulkEditRulesRequestBodyV1 = req.body; + const actionsClient = (await context.actions).getActionsClient(); + const bulkEditData: BulkEditRulesRequestBodyV1 = req.body; const { filter, operations, ids } = bulkEditData; try { const bulkEditResults = await rulesClient.bulkEdit({ filter, ids, - operations, + operations: transformOperationsV1({ + operations, + isSystemAction: (connectorId: string) => actionsClient.isSystemAction(connectorId), + }), }); const resultBody: BulkEditRulesResponseV1 = { diff --git a/x-pack/plugins/alerting/server/routes/rule/apis/bulk_edit/transforms/index.ts b/x-pack/plugins/alerting/server/routes/rule/apis/bulk_edit/transforms/index.ts new file mode 100644 index 0000000000000..e7d1a1dc43478 --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/rule/apis/bulk_edit/transforms/index.ts @@ -0,0 +1,10 @@ +/* + * 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. + */ + +export { transformOperations } from './transform_operations/latest'; + +export { transformOperations as transformOperationsV1 } from './transform_operations/v1'; diff --git a/x-pack/plugins/alerting/server/routes/rule/apis/bulk_edit/transforms/transform_operations/latest.ts b/x-pack/plugins/alerting/server/routes/rule/apis/bulk_edit/transforms/transform_operations/latest.ts new file mode 100644 index 0000000000000..25300c97a6d2e --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/rule/apis/bulk_edit/transforms/transform_operations/latest.ts @@ -0,0 +1,8 @@ +/* + * 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. + */ + +export * from './v1'; diff --git a/x-pack/plugins/alerting/server/routes/rule/apis/bulk_edit/transforms/transform_operations/v1.test.ts b/x-pack/plugins/alerting/server/routes/rule/apis/bulk_edit/transforms/transform_operations/v1.test.ts new file mode 100644 index 0000000000000..e186621490dea --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/rule/apis/bulk_edit/transforms/transform_operations/v1.test.ts @@ -0,0 +1,66 @@ +/* + * 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 { transformOperations } from './v1'; + +describe('transformOperations', () => { + const isSystemAction = (id: string) => id === 'my-system-action-id'; + + describe('actions', () => { + const defaultAction = { + id: 'default-action', + params: {}, + }; + + const systemAction = { + id: 'my-system-action-id', + params: {}, + }; + + it('transform the actions correctly', async () => { + expect( + transformOperations({ + operations: [ + { field: 'actions', operation: 'add', value: [defaultAction, systemAction] }, + ], + isSystemAction, + }) + ).toEqual([ + { + field: 'actions', + operation: 'add', + value: [ + { + group: 'default', + id: 'default-action', + params: {}, + type: 'default', + }, + { id: 'my-system-action-id', params: {}, type: 'system' }, + ], + }, + ]); + }); + + it('returns an empty array if the operations are empty', async () => { + expect( + transformOperations({ + operations: [], + isSystemAction, + }) + ).toEqual([]); + }); + + it('returns an empty array if the operations are undefined', async () => { + expect( + transformOperations({ + isSystemAction, + }) + ).toEqual([]); + }); + }); +}); diff --git a/x-pack/plugins/alerting/server/routes/rule/apis/bulk_edit/transforms/transform_operations/v1.ts b/x-pack/plugins/alerting/server/routes/rule/apis/bulk_edit/transforms/transform_operations/v1.ts new file mode 100644 index 0000000000000..aa88e9423f568 --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/rule/apis/bulk_edit/transforms/transform_operations/v1.ts @@ -0,0 +1,56 @@ +/* + * 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 { RuleActionTypes } from '../../../../../../../common'; +import { BulkEditOperation } from '../../../../../../application/rule/methods/bulk_edit'; +import { BulkEditRulesRequestBodyV1 } from '../../../../../../../common/routes/rule/apis/bulk_edit'; + +export const transformOperations = ({ + operations, + isSystemAction, +}: { + operations?: BulkEditRulesRequestBodyV1['operations']; + isSystemAction: (connectorId: string) => boolean; +}): BulkEditOperation[] => { + if (operations == null || operations.length === 0) { + return []; + } + + return operations.map((operation) => { + if (operation.field !== 'actions') { + return operation; + } + + const actions = operation.value.map((action) => { + if (isSystemAction(action.id)) { + return { + id: action.id, + params: action.params, + ...(action.uuid && { frequency: action.uuid }), + type: RuleActionTypes.SYSTEM, + }; + } + + return { + id: action.id, + group: action.group ?? 'default', + params: action.params, + uuid: action.uuid, + ...(action.frequency && { frequency: action.frequency }), + ...(action.uuid && { frequency: action.uuid }), + frequency: action.frequency, + type: RuleActionTypes.DEFAULT, + }; + }); + + return { + field: operation.field, + operation: operation.operation, + value: actions, + }; + }); +}; diff --git a/x-pack/plugins/alerting/server/routes/rule/apis/create/create_rule_route.test.ts b/x-pack/plugins/alerting/server/routes/rule/apis/create/create_rule_route.test.ts index e952a72ec3859..b84c53b9b5409 100644 --- a/x-pack/plugins/alerting/server/routes/rule/apis/create/create_rule_route.test.ts +++ b/x-pack/plugins/alerting/server/routes/rule/apis/create/create_rule_route.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { pick } from 'lodash'; +import { omit, pick } from 'lodash'; import { createRuleRoute } from './create_rule_route'; import { httpServiceMock } from '@kbn/core/server/mocks'; import { licenseStateMock } from '../../../../lib/license_state.mock'; @@ -15,9 +15,16 @@ import type { CreateRuleRequestBodyV1 } from '../../../../../common/routes/rule/ import { rulesClientMock } from '../../../../rules_client.mock'; import { RuleTypeDisabledError } from '../../../../lib'; import { AsApiContract } from '../../../lib'; -import { SanitizedRule } from '../../../../types'; +import { + RuleActionTypes, + RuleDefaultAction, + RuleSystemAction, + SanitizedRule, + SanitizedRuleResponse, +} from '../../../../types'; import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks'; import { usageCountersServiceMock } from '@kbn/usage-collection-plugin/server/usage_counters/usage_counters_service.mock'; +import { actionsClientMock } from '@kbn/actions-plugin/server/mocks'; const rulesClient = rulesClientMock.create(); @@ -32,6 +39,38 @@ beforeEach(() => { describe('createRuleRoute', () => { const createdAt = new Date(); const updatedAt = new Date(); + const action: RuleDefaultAction = { + actionTypeId: 'test', + group: 'default', + id: '2', + params: { + foo: true, + }, + uuid: '123-456', + alertsFilter: { + query: { + kql: 'name:test', + dsl: '{"must": {"term": { "name": "test" }}}', + filters: [], + }, + timeframe: { + days: [1], + hours: { start: '08:00', end: '17:00' }, + timezone: 'UTC', + }, + }, + type: RuleActionTypes.DEFAULT, + }; + + const systemAction: RuleSystemAction = { + actionTypeId: 'test-2', + id: 'system_action-id', + params: { + foo: true, + }, + uuid: '123-456', + type: RuleActionTypes.SYSTEM, + }; const mockedAlert: SanitizedRule<{ bar: boolean }> = { alertTypeId: '1', @@ -43,29 +82,7 @@ describe('createRuleRoute', () => { bar: true, }, throttle: '30s', - actions: [ - { - actionTypeId: 'test', - group: 'default', - id: '2', - params: { - foo: true, - }, - uuid: '123-456', - alertsFilter: { - query: { - kql: 'name:test', - dsl: '{"must": {"term": { "name": "test" }}}', - filters: [], - }, - timeframe: { - days: [1], - hours: { start: '08:00', end: '17:00' }, - timezone: 'UTC', - }, - }, - }, - ], + actions: [action], enabled: true, muteAll: false, createdBy: '', @@ -89,18 +106,21 @@ describe('createRuleRoute', () => { notify_when: mockedAlert.notifyWhen, actions: [ { - group: mockedAlert.actions[0].group, + group: action.group, id: mockedAlert.actions[0].id, params: mockedAlert.actions[0].params, alerts_filter: { - query: { kql: mockedAlert.actions[0].alertsFilter!.query!.kql, filters: [] }, - timeframe: mockedAlert.actions[0].alertsFilter?.timeframe!, + query: { + kql: action.alertsFilter!.query!.kql, + filters: [], + }, + timeframe: action.alertsFilter?.timeframe!, }, }, ], }; - const createResult: AsApiContract> = { + const createResult: AsApiContract> = { ...ruleToCreate, mute_all: mockedAlert.muteAll, created_by: mockedAlert.createdBy, @@ -119,8 +139,8 @@ describe('createRuleRoute', () => { { ...ruleToCreate.actions[0], alerts_filter: { - query: mockedAlert.actions[0].alertsFilter?.query!, - timeframe: mockedAlert.actions[0].alertsFilter!.timeframe!, + query: action.alertsFilter?.query!, + timeframe: action.alertsFilter!.timeframe!, }, connector_type_id: 'test', uuid: '123-456', @@ -198,6 +218,7 @@ describe('createRuleRoute', () => { "params": Object { "foo": true, }, + "type": "default", }, ], "alertTypeId": "1", @@ -314,6 +335,7 @@ describe('createRuleRoute', () => { "params": Object { "foo": true, }, + "type": "default", }, ], "alertTypeId": "1", @@ -431,6 +453,7 @@ describe('createRuleRoute', () => { "params": Object { "foo": true, }, + "type": "default", }, ], "alertTypeId": "1", @@ -548,6 +571,7 @@ describe('createRuleRoute', () => { "params": Object { "foo": true, }, + "type": "default", }, ], "alertTypeId": "1", @@ -646,4 +670,172 @@ describe('createRuleRoute', () => { expect(res.forbidden).toHaveBeenCalledWith({ body: { message: 'Fail' } }); }); + + describe('actions', () => { + it('adds the type of the actions correctly before passing the request to the rules client', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup({ canEncrypt: true }); + const mockUsageCountersSetup = usageCountersServiceMock.createSetupContract(); + const mockUsageCounter = mockUsageCountersSetup.createUsageCounter('test'); + const actionsClient = actionsClientMock.create(); + actionsClient.isSystemAction.mockImplementation((id: string) => id === 'system_action-id'); + + createRuleRoute({ + router, + licenseState, + encryptedSavedObjects, + usageCounter: mockUsageCounter, + }); + + const [_, handler] = router.post.mock.calls[0]; + + rulesClient.create.mockResolvedValueOnce({ ...mockedAlert, actions: [action, systemAction] }); + + const [context, req, res] = mockHandlerArguments( + { rulesClient, actionsClient }, + { + body: { ...ruleToCreate, actions: [omit(action, 'type'), omit(systemAction, 'type')] }, + }, + ['ok'] + ); + + await handler(context, req, res); + + expect(rulesClient.create.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "data": Object { + "actions": Array [ + Object { + "actionTypeId": "test", + "group": "default", + "id": "2", + "params": Object { + "foo": true, + }, + "type": "default", + "uuid": "123-456", + }, + Object { + "actionTypeId": "test-2", + "id": "system_action-id", + "params": Object { + "foo": true, + }, + "type": "system", + "uuid": "123-456", + }, + ], + "alertTypeId": "1", + "consumer": "bar", + "enabled": true, + "name": "abc", + "notifyWhen": "onActionGroupChange", + "params": Object { + "bar": true, + }, + "schedule": Object { + "interval": "10s", + }, + "tags": Array [ + "foo", + ], + "throttle": "30s", + }, + "options": Object { + "id": undefined, + }, + }, + ] + `); + }); + + it('removes the type from the actions correctly before sending the response', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup({ canEncrypt: true }); + const mockUsageCountersSetup = usageCountersServiceMock.createSetupContract(); + const mockUsageCounter = mockUsageCountersSetup.createUsageCounter('test'); + const actionsClient = actionsClientMock.create(); + actionsClient.isSystemAction.mockImplementation((id: string) => id === 'system_action-id'); + + createRuleRoute({ + router, + licenseState, + encryptedSavedObjects, + usageCounter: mockUsageCounter, + }); + + const [_, handler] = router.post.mock.calls[0]; + + rulesClient.create.mockResolvedValueOnce({ ...mockedAlert, actions: [action, systemAction] }); + + const [context, req, res] = mockHandlerArguments( + { rulesClient, actionsClient }, + { + body: ruleToCreate, + }, + ['ok'] + ); + + const routeRes = await handler(context, req, res); + + // @ts-expect-error: body exists + expect(routeRes.body.actions).toEqual([ + { + alerts_filter: { + query: { dsl: '{"must": {"term": { "name": "test" }}}', filters: [], kql: 'name:test' }, + timeframe: { days: [1], hours: { end: '17:00', start: '08:00' }, timezone: 'UTC' }, + }, + connector_type_id: 'test', + group: 'default', + id: '2', + params: { foo: true }, + uuid: '123-456', + }, + { + connector_type_id: 'test-2', + id: 'system_action-id', + params: { foo: true }, + uuid: '123-456', + }, + ]); + }); + + it('fails if the action contains a type in the request', async () => { + const actionToValidate: RuleDefaultAction = { + actionTypeId: 'test', + group: 'default', + id: '2', + params: { + foo: true, + }, + uuid: '123-456', + type: RuleActionTypes.DEFAULT, + }; + + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup({ canEncrypt: true }); + const mockUsageCountersSetup = usageCountersServiceMock.createSetupContract(); + const mockUsageCounter = mockUsageCountersSetup.createUsageCounter('test'); + + createRuleRoute({ + router, + licenseState, + encryptedSavedObjects, + usageCounter: mockUsageCounter, + }); + + const [config, _] = router.post.mock.calls[0]; + + expect(() => + // @ts-expect-error: body exists + config.validate.body.validate({ ...ruleToCreate, actions: [actionToValidate] }) + ).toThrowErrorMatchingInlineSnapshot( + `"[actions.0.type]: definition for this key is missing"` + ); + }); + }); }); diff --git a/x-pack/plugins/alerting/server/routes/rule/apis/create/create_rule_route.ts b/x-pack/plugins/alerting/server/routes/rule/apis/create/create_rule_route.ts index 6b28b64284904..a6b252497bace 100644 --- a/x-pack/plugins/alerting/server/routes/rule/apis/create/create_rule_route.ts +++ b/x-pack/plugins/alerting/server/routes/rule/apis/create/create_rule_route.ts @@ -13,7 +13,7 @@ import { } from '../../../lib'; import { BASE_ALERTING_API_PATH } from '../../../../types'; import { RouteOptions } from '../../..'; -import type { +import { CreateRuleRequestBodyV1, CreateRuleRequestParamsV1, CreateRuleResponseV1, @@ -40,6 +40,7 @@ export const createRuleRoute = ({ router, licenseState, usageCounter }: RouteOpt router.handleLegacyErrors( verifyAccessAndContext(licenseState, async function (context, req, res) { const rulesClient = (await context.alerting).getRulesClient(); + const actionsClient = (await context.actions).getActionsClient(); // Assert versioned inputs const createRuleData: CreateRuleRequestBodyV1 = req.body; @@ -55,7 +56,9 @@ export const createRuleRoute = ({ router, licenseState, usageCounter }: RouteOpt // TODO (http-versioning): Remove this cast, this enables us to move forward // without fixing all of other solution types const createdRule: Rule = (await rulesClient.create({ - data: transformCreateBodyV1(createRuleData), + data: transformCreateBodyV1(createRuleData, (connectorId: string) => + actionsClient.isSystemAction(connectorId) + ), options: { id: params?.id }, })) as Rule; diff --git a/x-pack/plugins/alerting/server/routes/rule/apis/create/transforms/transform_create_body/v1.test.ts b/x-pack/plugins/alerting/server/routes/rule/apis/create/transforms/transform_create_body/v1.test.ts new file mode 100644 index 0000000000000..5105ce8887423 --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/rule/apis/create/transforms/transform_create_body/v1.test.ts @@ -0,0 +1,99 @@ +/* + * 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 { RuleActionTypes } from '../../../../../../../common'; +import { CreateRuleRequestBodyV1 } from '../../../../../../../common/routes/rule/apis/create'; +import { transformCreateBody } from './v1'; + +describe('transformCreateBody', () => { + const isSystemAction = (id: string) => id === 'system-action-id'; + + const defaultAction: CreateRuleRequestBodyV1['actions'][number] = { + id: '1', + uuid: '111', + params: { foo: 'bar' }, + group: 'my-group', + actionTypeId: '.test', + frequency: { notify_when: 'onThrottleInterval' as const, summary: true, throttle: '1h' }, + alerts_filter: { + query: { dsl: '{test:1}', kql: 'test:1s', filters: [] }, + }, + }; + + const systemAction: CreateRuleRequestBodyV1['actions'][number] = { + id: 'system-action-id', + uuid: '111', + params: { foo: 'bar' }, + actionTypeId: '.test', + }; + + const rule: CreateRuleRequestBodyV1<{}> = { + enabled: true, + name: 'stressing index-threshold 37/200', + tags: [], + params: {}, + rule_type_id: '.index-threshold', + consumer: 'alerts', + schedule: { + interval: '1s', + }, + actions: [], + notify_when: 'onActiveAlert' as const, + throttle: null, + }; + + it('transforms the default action correctly', async () => { + const res = transformCreateBody({ ...rule, actions: [defaultAction] }, isSystemAction); + + expect(res.actions).toEqual([ + { + actionTypeId: '.test', + alertsFilter: { query: { dsl: '{test:1}', filters: [], kql: 'test:1s' } }, + frequency: { notifyWhen: 'onThrottleInterval', summary: true, throttle: '1h' }, + group: 'my-group', + id: '1', + params: { foo: 'bar' }, + type: RuleActionTypes.DEFAULT, + uuid: '111', + }, + ]); + }); + + it('sets the group to default if it is undefined', async () => { + const { group, ...defaultActionWithoutGroup } = defaultAction; + const res = transformCreateBody( + { ...rule, actions: [defaultActionWithoutGroup] }, + isSystemAction + ); + + expect(res.actions).toEqual([ + { + actionTypeId: '.test', + alertsFilter: { query: { dsl: '{test:1}', filters: [], kql: 'test:1s' } }, + frequency: { notifyWhen: 'onThrottleInterval', summary: true, throttle: '1h' }, + group: 'default', + id: '1', + params: { foo: 'bar' }, + type: RuleActionTypes.DEFAULT, + uuid: '111', + }, + ]); + }); + + it('transforms the system action correctly', async () => { + const res = transformCreateBody({ ...rule, actions: [systemAction] }, isSystemAction); + expect(res.actions).toEqual([ + { + actionTypeId: '.test', + id: 'system-action-id', + params: { foo: 'bar' }, + type: RuleActionTypes.SYSTEM, + uuid: '111', + }, + ]); + }); +}); diff --git a/x-pack/plugins/alerting/server/routes/rule/apis/create/transforms/transform_create_body/v1.ts b/x-pack/plugins/alerting/server/routes/rule/apis/create/transforms/transform_create_body/v1.ts index c6b29f4577f4c..51ccc96d4e9c6 100644 --- a/x-pack/plugins/alerting/server/routes/rule/apis/create/transforms/transform_create_body/v1.ts +++ b/x-pack/plugins/alerting/server/routes/rule/apis/create/transforms/transform_create_body/v1.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { RuleActionTypes } from '../../../../../../../common'; import type { CreateRuleActionV1, CreateRuleRequestBodyV1, @@ -12,12 +13,25 @@ import type { import type { CreateRuleData } from '../../../../../../application/rule/methods/create'; import type { RuleParams } from '../../../../../../application/rule/types'; -const transformCreateBodyActions = (actions: CreateRuleActionV1[]): CreateRuleData['actions'] => { +const transformCreateBodyActions = ( + actions: CreateRuleActionV1[], + isSystemAction: (connectorId: string) => boolean +): CreateRuleData['actions'] => { if (!actions) return []; return actions.map(({ frequency, alerts_filter: alertsFilter, ...action }) => { + if (isSystemAction(action.id)) { + return { + id: action.id, + params: action.params, + actionTypeId: action.actionTypeId, + ...(action.uuid ? { uuid: action.uuid } : {}), + type: RuleActionTypes.SYSTEM, + }; + } + return { - group: action.group, + group: action.group ?? 'default', id: action.id, params: action.params, actionTypeId: action.actionTypeId, @@ -32,12 +46,14 @@ const transformCreateBodyActions = (actions: CreateRuleActionV1[]): CreateRuleDa } : {}), ...(alertsFilter ? { alertsFilter } : {}), + type: RuleActionTypes.DEFAULT, }; }); }; export const transformCreateBody = ( - createBody: CreateRuleRequestBodyV1 + createBody: CreateRuleRequestBodyV1, + isSystemAction: (connectorId: string) => boolean ): CreateRuleData => { return { name: createBody.name, @@ -48,7 +64,7 @@ export const transformCreateBody = ( ...(createBody.throttle ? { throttle: createBody.throttle } : {}), params: createBody.params, schedule: createBody.schedule, - actions: transformCreateBodyActions(createBody.actions), + actions: transformCreateBodyActions(createBody.actions, isSystemAction), ...(createBody.notify_when ? { notifyWhen: createBody.notify_when } : {}), }; }; diff --git a/x-pack/plugins/alerting/server/routes/rule/transforms/transform_rule_to_rule_response/v1.test.ts b/x-pack/plugins/alerting/server/routes/rule/transforms/transform_rule_to_rule_response/v1.test.ts new file mode 100644 index 0000000000000..b3f041fe26632 --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/rule/transforms/transform_rule_to_rule_response/v1.test.ts @@ -0,0 +1,104 @@ +/* + * 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 { RuleActionTypes, RuleDefaultAction, RuleSystemAction } from '../../../../../common'; +import { transformRuleToRuleResponse } from './v1'; + +describe('transformRuleToRuleResponse', () => { + const defaultAction: RuleDefaultAction = { + id: '1', + uuid: '111', + params: { foo: 'bar' }, + group: 'default', + actionTypeId: '.test', + frequency: { notifyWhen: 'onThrottleInterval', summary: true, throttle: '1h' }, + alertsFilter: { + query: { dsl: '{test:1}', kql: 'test:1s', filters: [] }, + timeframe: { + days: [1, 2, 3], + hours: { end: '15:00', start: '00:00' }, + timezone: 'UTC', + }, + }, + type: RuleActionTypes.DEFAULT, + }; + + const systemAction: RuleSystemAction = { + id: '1', + uuid: '111', + params: { foo: 'bar' }, + actionTypeId: '.test', + type: RuleActionTypes.SYSTEM, + }; + + const rule = { + id: '3d534c70-582b-11ec-8995-2b1578a3bc5d', + enabled: true, + name: 'stressing index-threshold 37/200', + tags: [], + alertTypeId: '.index-threshold', + consumer: 'alerts', + schedule: { + interval: '1s', + }, + actions: [], + params: {}, + createdBy: 'elastic', + updatedBy: '2889684073', + createdAt: new Date('2023-08-01T09:16:35.368Z'), + updatedAt: new Date('2023-08-01T09:16:35.368Z'), + notifyWhen: 'onActiveAlert' as const, + throttle: null, + apiKey: null, + apiKeyOwner: '2889684073', + muteAll: false, + mutedInstanceIds: [], + scheduledTaskId: '52125fb0-5895-11ec-ae69-bb65d1a71b72', + executionStatus: { + status: 'ok' as const, + lastExecutionDate: new Date('2023-08-01T09:16:35.368Z'), + lastDuration: 1194, + }, + revision: 0, + }; + + describe('actions', () => { + it('transforms a default action correctly', () => { + const res = transformRuleToRuleResponse({ ...rule, actions: [defaultAction] }); + expect(res.actions).toEqual([ + { + alerts_filter: { + query: { dsl: '{test:1}', filters: [], kql: 'test:1s' }, + timeframe: { + days: [1, 2, 3], + hours: { end: '15:00', start: '00:00' }, + timezone: 'UTC', + }, + }, + connector_type_id: '.test', + frequency: { notify_when: 'onThrottleInterval', summary: true, throttle: '1h' }, + group: 'default', + id: '1', + params: { foo: 'bar' }, + uuid: '111', + }, + ]); + }); + + it('transforms a system action correctly', () => { + const res = transformRuleToRuleResponse({ ...rule, actions: [systemAction] }); + expect(res.actions).toEqual([ + { + id: '1', + uuid: '111', + params: { foo: 'bar' }, + connector_type_id: '.test', + }, + ]); + }); + }); +}); diff --git a/x-pack/plugins/alerting/server/routes/rule/transforms/transform_rule_to_rule_response/v1.ts b/x-pack/plugins/alerting/server/routes/rule/transforms/transform_rule_to_rule_response/v1.ts index e43427fe4470a..08b915b744bc0 100644 --- a/x-pack/plugins/alerting/server/routes/rule/transforms/transform_rule_to_rule_response/v1.ts +++ b/x-pack/plugins/alerting/server/routes/rule/transforms/transform_rule_to_rule_response/v1.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { RuleActionTypes } from '../../../../../common'; import { RuleResponseV1, RuleParamsV1, @@ -38,18 +39,16 @@ const transformMonitoring = (monitoring: Monitoring): MonitoringV1 => { }; }; -export const transformRuleToRuleResponse = ( - rule: Rule -): RuleResponseV1 => ({ - id: rule.id, - enabled: rule.enabled, - name: rule.name, - tags: rule.tags, - rule_type_id: rule.alertTypeId, - consumer: rule.consumer, - schedule: rule.schedule, - actions: rule.actions.map( - ({ group, id, actionTypeId, params, frequency, uuid, alertsFilter }) => ({ +const transformRuleActions = (actions: Rule['actions']): RuleResponseV1['actions'] => { + return actions.map((action) => { + if (action.type === RuleActionTypes.SYSTEM) { + const { id, actionTypeId, params, uuid } = action; + return { id, params, uuid, connector_type_id: actionTypeId }; + } + + const { group, id, actionTypeId, params, frequency, uuid, alertsFilter } = action; + + return { group, id, params, @@ -65,8 +64,21 @@ export const transformRuleToRuleResponse = ( : {}), ...(uuid && { uuid }), ...(alertsFilter && { alerts_filter: alertsFilter }), - }) - ), + }; + }); +}; + +export const transformRuleToRuleResponse = ( + rule: Rule +): RuleResponseV1 => ({ + id: rule.id, + enabled: rule.enabled, + name: rule.name, + tags: rule.tags, + rule_type_id: rule.alertTypeId, + consumer: rule.consumer, + schedule: rule.schedule, + actions: transformRuleActions(rule.actions), params: rule.params, ...(rule.mapped_params ? { mapped_params: rule.mapped_params } : {}), ...(rule.scheduledTaskId !== undefined ? { scheduled_task_id: rule.scheduledTaskId } : {}), diff --git a/x-pack/plugins/alerting/server/routes/update_rule.test.ts b/x-pack/plugins/alerting/server/routes/update_rule.test.ts index 5a4b3a19c0d7c..0e0170842fb8e 100644 --- a/x-pack/plugins/alerting/server/routes/update_rule.test.ts +++ b/x-pack/plugins/alerting/server/routes/update_rule.test.ts @@ -5,18 +5,18 @@ * 2.0. */ -import { pick } from 'lodash'; +import { omit, pick } from 'lodash'; import { updateRuleRoute } from './update_rule'; import { httpServiceMock } from '@kbn/core/server/mocks'; import { licenseStateMock } from '../lib/license_state.mock'; import { verifyApiAccess } from '../lib/license_api_access'; import { mockHandlerArguments } from './_mock_handler_arguments'; -import { UpdateOptions } from '../rules_client'; import { rulesClientMock } from '../rules_client.mock'; import { RuleTypeDisabledError } from '../lib/errors/rule_type_disabled'; -import { RuleNotifyWhen } from '../../common'; +import { RuleActionTypes, RuleDefaultAction, RuleNotifyWhen, RuleSystemAction } from '../../common'; import { AsApiContract } from './lib'; -import { PartialRule } from '../types'; +import { PartialRuleResponse } from '../types'; +import { actionsClientMock } from '@kbn/actions-plugin/server/mocks'; const rulesClient = rulesClientMock.create(); jest.mock('../lib/license_api_access', () => ({ @@ -28,6 +28,34 @@ beforeEach(() => { }); describe('updateRuleRoute', () => { + const action: RuleDefaultAction = { + uuid: '1234-5678', + group: 'default', + id: '2', + actionTypeId: 'test', + params: { + baz: true, + }, + alertsFilter: { + query: { + kql: 'name:test', + dsl: '{"must": {"term": { "name": "test" }}}', + filters: [], + }, + }, + type: RuleActionTypes.DEFAULT, + }; + + const systemAction: RuleSystemAction = { + actionTypeId: 'test-2', + id: 'system_action-id', + params: { + foo: true, + }, + uuid: '123-456', + type: RuleActionTypes.SYSTEM, + }; + const mockedAlert = { id: '1', name: 'abc', @@ -40,34 +68,17 @@ describe('updateRuleRoute', () => { }, createdAt: new Date(), updatedAt: new Date(), - actions: [ - { - uuid: '1234-5678', - group: 'default', - id: '2', - actionTypeId: 'test', - params: { - baz: true, - }, - alertsFilter: { - query: { - kql: 'name:test', - dsl: '{"must": {"term": { "name": "test" }}}', - filters: [], - }, - }, - }, - ], + actions: [action], notifyWhen: RuleNotifyWhen.CHANGE, }; - const updateRequest: AsApiContract['data']> = { + const updateRequest = { ...pick(mockedAlert, 'name', 'tags', 'schedule', 'params', 'throttle'), notify_when: mockedAlert.notifyWhen, actions: [ { uuid: '1234-5678', - group: mockedAlert.actions[0].group, + group: action.group, id: mockedAlert.actions[0].id, params: mockedAlert.actions[0].params, alerts_filter: mockedAlert.actions[0].alertsFilter, @@ -75,17 +86,27 @@ describe('updateRuleRoute', () => { ], }; - const updateResult: AsApiContract> = { + const updateResult: AsApiContract> = { ...updateRequest, id: mockedAlert.id, updated_at: mockedAlert.updatedAt, created_at: mockedAlert.createdAt, rule_type_id: mockedAlert.alertTypeId, - actions: mockedAlert.actions.map(({ actionTypeId, alertsFilter, ...rest }) => ({ - ...rest, - connector_type_id: actionTypeId, - alerts_filter: alertsFilter, - })), + actions: mockedAlert.actions.map( + ({ actionTypeId, alertsFilter, frequency, type, ...rest }) => ({ + ...rest, + connector_type_id: actionTypeId, + alerts_filter: alertsFilter, + ...(frequency + ? { + frequency: { + ...frequency, + notify_when: frequency?.notifyWhen, + }, + } + : {}), + }) + ), }; it('updates a rule with proper parameters', async () => { @@ -132,6 +153,7 @@ describe('updateRuleRoute', () => { "params": Object { "baz": true, }, + "type": "default", "uuid": "1234-5678", }, ], @@ -231,4 +253,151 @@ describe('updateRuleRoute', () => { expect(res.forbidden).toHaveBeenCalledWith({ body: { message: 'Fail' } }); }); + + describe('actions', () => { + it('adds the type of the actions correctly before passing the request to the rules client', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + const actionsClient = actionsClientMock.create(); + actionsClient.isSystemAction.mockImplementation((id: string) => id === 'system_action-id'); + + updateRuleRoute(router, licenseState); + + const [_, handler] = router.put.mock.calls[0]; + rulesClient.update.mockResolvedValueOnce({ ...mockedAlert, actions: [action, systemAction] }); + + const [context, req, res] = mockHandlerArguments( + { rulesClient, actionsClient }, + { + params: { + id: '1', + }, + body: { ...updateRequest, actions: [omit(action, 'type'), omit(systemAction, 'type')] }, + }, + ['ok'] + ); + + await handler(context, req, res); + + expect(rulesClient.update.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "data": Object { + "actions": Array [ + Object { + "group": "default", + "id": "2", + "params": Object { + "baz": true, + }, + "type": "default", + "uuid": "1234-5678", + }, + Object { + "id": "system_action-id", + "params": Object { + "foo": true, + }, + "type": "system", + "uuid": "123-456", + }, + ], + "name": "abc", + "notifyWhen": "onActionGroupChange", + "params": Object { + "otherField": false, + }, + "schedule": Object { + "interval": "12s", + }, + "tags": Array [ + "foo", + ], + "throttle": "10m", + }, + "id": "1", + }, + ] + `); + }); + + it('removes the type from the actions correctly before sending the response', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + const actionsClient = actionsClientMock.create(); + actionsClient.isSystemAction.mockImplementation((id: string) => id === 'system_action-id'); + + updateRuleRoute(router, licenseState); + + const [_, handler] = router.put.mock.calls[0]; + rulesClient.update.mockResolvedValueOnce({ ...mockedAlert, actions: [action, systemAction] }); + + const [context, req, res] = mockHandlerArguments( + { rulesClient, actionsClient }, + { + params: { + id: '1', + }, + body: { ...updateRequest, actions: [omit(action, 'type'), omit(systemAction, 'type')] }, + }, + ['ok'] + ); + + const routeRes = await handler(context, req, res); + + // @ts-expect-error: body exists + expect(routeRes.body.actions).toEqual([ + { + alerts_filter: { + query: { + dsl: '{"must": {"term": { "name": "test" }}}', + filters: [], + kql: 'name:test', + }, + }, + connector_type_id: 'test', + group: 'default', + id: '2', + params: { + baz: true, + }, + uuid: '1234-5678', + }, + { + connector_type_id: 'test-2', + id: 'system_action-id', + params: { + foo: true, + }, + uuid: '123-456', + }, + ]); + }); + + it('fails if the action contains a type in the request', async () => { + const actionToValidate = { + group: 'default', + id: '2', + params: { + foo: true, + }, + uuid: '123-456', + type: RuleActionTypes.DEFAULT, + }; + + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + updateRuleRoute(router, licenseState); + + const [config, _] = router.put.mock.calls[0]; + + expect(() => + // @ts-expect-error: body exists + config.validate.body.validate({ ...updateRequest, actions: [actionToValidate] }) + ).toThrowErrorMatchingInlineSnapshot( + `"[actions.0.type]: definition for this key is missing"` + ); + }); + }); }); diff --git a/x-pack/plugins/alerting/server/routes/update_rule.ts b/x-pack/plugins/alerting/server/routes/update_rule.ts index d24af256de613..e31b49f0644dd 100644 --- a/x-pack/plugins/alerting/server/routes/update_rule.ts +++ b/x-pack/plugins/alerting/server/routes/update_rule.ts @@ -5,25 +5,25 @@ * 2.0. */ -import { schema } from '@kbn/config-schema'; +import { schema, TypeOf } from '@kbn/config-schema'; import { IRouter } from '@kbn/core/server'; import { ILicenseState, RuleTypeDisabledError, validateDurationSchema } from '../lib'; import { UpdateOptions } from '../rules_client'; import { verifyAccessAndContext, - RewriteResponseCase, - RewriteRequestCase, handleDisabledApiKeysError, rewriteActionsReq, rewriteActionsRes, actionsSchema, rewriteRuleLastRun, + AsApiContract, } from './lib'; import { RuleTypeParams, AlertingRequestHandlerContext, BASE_ALERTING_API_PATH, validateNotifyWhenType, + PartialRuleResponse, PartialRule, } from '../types'; @@ -54,18 +54,21 @@ const bodySchema = schema.object({ ), }); -const rewriteBodyReq: RewriteRequestCase> = (result) => { - const { notify_when: notifyWhen, actions, ...rest } = result.data; +type Data = TypeOf; + +const rewriteBodyReq = ( + data: Data, + isSystemAction: (connectorId: string) => boolean +): UpdateOptions['data'] => { + const { notify_when: notifyWhen, actions, ...rest } = data; return { - ...result, - data: { - ...rest, - notifyWhen, - actions: rewriteActionsReq(actions), - }, + ...rest, + notifyWhen, + actions: rewriteActionsReq(actions, isSystemAction), }; }; -const rewriteBodyRes: RewriteResponseCase> = ({ + +const rewriteBodyRes = ({ actions, alertTypeId, scheduledTaskId, @@ -84,7 +87,7 @@ const rewriteBodyRes: RewriteResponseCase> = ({ lastRun, nextRun, ...rest -}) => ({ +}: PartialRule): AsApiContract> => ({ ...rest, api_key_owner: apiKeyOwner, created_by: createdBy, @@ -133,10 +136,19 @@ export const updateRuleRoute = ( router.handleLegacyErrors( verifyAccessAndContext(licenseState, async function (context, req, res) { const rulesClient = (await context.alerting).getRulesClient(); + const actionsClient = (await context.actions).getActionsClient(); + const { id } = req.params; const rule = req.body; + try { - const alertRes = await rulesClient.update(rewriteBodyReq({ id, data: rule })); + const alertRes = await rulesClient.update({ + id, + data: rewriteBodyReq(rule, (connectorId: string) => + actionsClient.isSystemAction(connectorId) + ), + }); + return res.ok({ body: rewriteBodyRes(alertRes), }); diff --git a/x-pack/plugins/alerting/server/rules_client/common/apply_bulk_edit_operation.test.ts b/x-pack/plugins/alerting/server/rules_client/common/apply_bulk_edit_operation.test.ts index 49ed183ceb39d..0ca691dc9dded 100644 --- a/x-pack/plugins/alerting/server/rules_client/common/apply_bulk_edit_operation.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/common/apply_bulk_edit_operation.test.ts @@ -6,7 +6,7 @@ */ import { applyBulkEditOperation } from './apply_bulk_edit_operation'; -import type { Rule } from '../../types'; +import { Rule, RuleActionTypes } from '../../types'; describe('applyBulkEditOperation', () => { describe('tags operations', () => { @@ -180,61 +180,120 @@ describe('applyBulkEditOperation', () => { describe('actions operations', () => { test('should add actions', () => { const ruleMock = { - actions: [{ id: 'mock-action-id', group: 'default', params: {} }], + actions: [ + { id: 'mock-action-id', group: 'default', params: {}, type: RuleActionTypes.DEFAULT }, + ], }; + const { modifiedAttributes, isAttributeModified } = applyBulkEditOperation( { field: 'actions', value: [ - { id: 'mock-add-action-id-1', group: 'default', params: {} }, - { id: 'mock-add-action-id-2', group: 'default', params: {} }, + { + id: 'mock-add-action-id-1', + group: 'default', + params: {}, + type: RuleActionTypes.DEFAULT, + }, + { + id: 'mock-add-action-id-2', + group: 'default', + params: {}, + type: RuleActionTypes.DEFAULT, + }, ], operation: 'add', }, ruleMock ); + expect(modifiedAttributes).toHaveProperty('actions', [ - { id: 'mock-action-id', group: 'default', params: {} }, - { id: 'mock-add-action-id-1', group: 'default', params: {} }, - { id: 'mock-add-action-id-2', group: 'default', params: {} }, + { id: 'mock-action-id', group: 'default', params: {}, type: RuleActionTypes.DEFAULT }, + { id: 'mock-add-action-id-1', group: 'default', params: {}, type: RuleActionTypes.DEFAULT }, + { id: 'mock-add-action-id-2', group: 'default', params: {}, type: RuleActionTypes.DEFAULT }, ]); + expect(isAttributeModified).toBe(true); }); test('should add action with different params and same id', () => { const ruleMock = { - actions: [{ id: 'mock-action-id', group: 'default', params: { test: 1 } }], + actions: [ + { + id: 'mock-action-id', + group: 'default', + params: { test: 1 }, + type: RuleActionTypes.DEFAULT, + }, + ], }; + const { modifiedAttributes, isAttributeModified } = applyBulkEditOperation( { field: 'actions', - value: [{ id: 'mock-action-id', group: 'default', params: { test: 2 } }], + value: [ + { + id: 'mock-action-id', + group: 'default', + params: { test: 2 }, + type: RuleActionTypes.DEFAULT, + }, + ], operation: 'add', }, ruleMock ); + expect(modifiedAttributes).toHaveProperty('actions', [ - { id: 'mock-action-id', group: 'default', params: { test: 1 } }, - { id: 'mock-action-id', group: 'default', params: { test: 2 } }, + { + id: 'mock-action-id', + group: 'default', + params: { test: 1 }, + type: RuleActionTypes.DEFAULT, + }, + { + id: 'mock-action-id', + group: 'default', + params: { test: 2 }, + type: RuleActionTypes.DEFAULT, + }, ]); + expect(isAttributeModified).toBe(true); }); test('should rewrite actions', () => { const ruleMock = { - actions: [{ id: 'mock-action-id', group: 'default', params: {} }], + actions: [ + { id: 'mock-action-id', group: 'default', params: {}, type: RuleActionTypes.DEFAULT }, + ], }; + const { modifiedAttributes, isAttributeModified } = applyBulkEditOperation( { field: 'actions', - value: [{ id: 'mock-rewrite-action-id-1', group: 'default', params: {} }], + value: [ + { + id: 'mock-rewrite-action-id-1', + group: 'default', + params: {}, + type: RuleActionTypes.DEFAULT, + }, + ], operation: 'set', }, ruleMock ); + expect(modifiedAttributes).toHaveProperty('actions', [ - { id: 'mock-rewrite-action-id-1', group: 'default', params: {} }, + { + id: 'mock-rewrite-action-id-1', + group: 'default', + params: {}, + type: RuleActionTypes.DEFAULT, + }, ]); + expect(isAttributeModified).toBe(true); }); }); diff --git a/x-pack/plugins/alerting/server/rules_client/common/inject_references.ts b/x-pack/plugins/alerting/server/rules_client/common/inject_references.ts index bbf192e4f1cf4..2938c91372325 100644 --- a/x-pack/plugins/alerting/server/rules_client/common/inject_references.ts +++ b/x-pack/plugins/alerting/server/rules_client/common/inject_references.ts @@ -9,7 +9,7 @@ import Boom from '@hapi/boom'; import { omit } from 'lodash'; import { SavedObjectReference, SavedObjectAttributes } from '@kbn/core/server'; import { UntypedNormalizedRuleType } from '../../rule_type_registry'; -import { Rule, RawRule, RuleTypeParams } from '../../types'; +import { RawRule, RuleTypeParams } from '../../types'; import { RuleActionAttributes } from '../../data/rule/types'; import { preconfiguredConnectorActionRefPrefix, @@ -45,7 +45,7 @@ export function injectReferencesIntoActions( ...omit(action, 'actionRef'), id: reference.id, }; - }) as Rule['actions']; + }); } export function injectReferencesIntoParams< diff --git a/x-pack/plugins/alerting/server/rules_client/lib/add_generated_action_values.test.ts b/x-pack/plugins/alerting/server/rules_client/lib/add_generated_action_values.test.ts index 6605d9c22a72b..bb52ba25e0a4a 100644 --- a/x-pack/plugins/alerting/server/rules_client/lib/add_generated_action_values.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/lib/add_generated_action_values.test.ts @@ -6,14 +6,14 @@ */ import { addGeneratedActionValues } from './add_generated_action_values'; -import { RuleAction } from '../../../common'; +import { RuleActionTypes, RuleDefaultAction, RuleSystemAction } from '../../../common'; jest.mock('uuid', () => ({ v4: () => '111-222', })); describe('addGeneratedActionValues()', () => { - const mockAction: RuleAction = { + const mockAction: RuleDefaultAction = { id: '1', group: 'default', actionTypeId: 'slack', @@ -39,6 +39,15 @@ describe('addGeneratedActionValues()', () => { timezone: 'UTC', }, }, + type: RuleActionTypes.DEFAULT, + }; + + const systemAction: RuleSystemAction = { + id: '1', + actionTypeId: '.test', + params: {}, + uuid: 'my-uid', + type: RuleActionTypes.SYSTEM, }; test('adds uuid', async () => { @@ -46,9 +55,22 @@ describe('addGeneratedActionValues()', () => { expect(actionWithGeneratedValues[0].uuid).toBe('111-222'); }); + test('adds uuid to a system action', async () => { + const { uuid, ...systemActionWithoutUUID } = systemAction; + const actionWithGeneratedValues = addGeneratedActionValues([systemActionWithoutUUID]); + expect(actionWithGeneratedValues[0].uuid).toBe('111-222'); + }); + + test('does not overrides the uuid of a system action', async () => { + const actionWithGeneratedValues = addGeneratedActionValues([systemAction]); + expect(actionWithGeneratedValues[0].uuid).toBe('my-uid'); + }); + test('adds DSL', async () => { const actionWithGeneratedValues = addGeneratedActionValues([mockAction]); - expect(actionWithGeneratedValues[0].alertsFilter?.query?.dsl).toBe( + const defaultAction = actionWithGeneratedValues[0] as RuleDefaultAction; + + expect(defaultAction.alertsFilter?.query?.dsl).toBe( '{"bool":{"must":[],"filter":[{"bool":{"should":[{"match":{"test":"testValue"}}],"minimum_should_match":1}},{"match_phrase":{"foo":"bar "}}],"should":[],"must_not":[]}}' ); }); diff --git a/x-pack/plugins/alerting/server/rules_client/lib/add_generated_action_values.ts b/x-pack/plugins/alerting/server/rules_client/lib/add_generated_action_values.ts index 71cbf01ea1a4e..4212cb766f7f5 100644 --- a/x-pack/plugins/alerting/server/rules_client/lib/add_generated_action_values.ts +++ b/x-pack/plugins/alerting/server/rules_client/lib/add_generated_action_values.ts @@ -9,12 +9,15 @@ import { v4 } from 'uuid'; import { buildEsQuery, Filter } from '@kbn/es-query'; import Boom from '@hapi/boom'; import { NormalizedAlertAction, NormalizedAlertActionWithGeneratedValues } from '..'; +import { RuleActionTypes } from '../../types'; export function addGeneratedActionValues( actions: NormalizedAlertAction[] = [] ): NormalizedAlertActionWithGeneratedValues[] { - return actions.map(({ uuid, alertsFilter, ...action }) => { - const generateDSL = (kql: string, filters: Filter[]) => { + return actions.map((action) => { + const uuid = action.uuid ?? v4(); + + const generateDSL = (kql: string, filters: Filter[]): string => { try { return JSON.stringify( buildEsQuery(undefined, [{ query: kql, language: 'kuery' }], filters) @@ -24,9 +27,15 @@ export function addGeneratedActionValues( } }; + if (action.type === RuleActionTypes.SYSTEM) { + return Object.assign(action, { uuid }); + } + + const { alertsFilter } = action; + return { ...action, - uuid: uuid || v4(), + uuid, ...(alertsFilter ? { alertsFilter: { @@ -40,6 +49,6 @@ export function addGeneratedActionValues( }, } : {}), - }; + } as NormalizedAlertActionWithGeneratedValues; }); } diff --git a/x-pack/plugins/alerting/server/rules_client/lib/create_new_api_key_set.test.ts b/x-pack/plugins/alerting/server/rules_client/lib/create_new_api_key_set.test.ts index 4b4a8ae26b8a7..418b78a7fd056 100644 --- a/x-pack/plugins/alerting/server/rules_client/lib/create_new_api_key_set.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/lib/create_new_api_key_set.test.ts @@ -54,6 +54,7 @@ const rulesClientParams: jest.Mocked = { isAuthenticationTypeAPIKey: jest.fn(), getAuthenticationAPIKey: jest.fn(), connectorAdapterRegistry: new ConnectorAdapterRegistry(), + isSystemAction: jest.fn(), }; const username = 'test'; diff --git a/x-pack/plugins/alerting/server/rules_client/lib/denormalize_actions.test.ts b/x-pack/plugins/alerting/server/rules_client/lib/denormalize_actions.test.ts new file mode 100644 index 0000000000000..9d3b4ceb49efe --- /dev/null +++ b/x-pack/plugins/alerting/server/rules_client/lib/denormalize_actions.test.ts @@ -0,0 +1,356 @@ +/* + * 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 { + savedObjectsClientMock, + loggingSystemMock, + savedObjectsRepositoryMock, +} from '@kbn/core/server/mocks'; +import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks'; +import { ruleTypeRegistryMock } from '../../rule_type_registry.mock'; +import { alertingAuthorizationMock } from '../../authorization/alerting_authorization.mock'; +import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks'; +import { actionsAuthorizationMock } from '@kbn/actions-plugin/server/mocks'; +import { AlertingAuthorization } from '../../authorization/alerting_authorization'; +import { ActionsAuthorization, ActionsClient } from '@kbn/actions-plugin/server'; +import { RulesClientContext } from '../types'; +import { ConnectorAdapterRegistry } from '../../connector_adapters/connector_adapter_registry'; +import { RuleActionTypes } from '../../types'; +import { getBeforeSetup } from '../tests/lib'; +import { denormalizeActions } from './denormalize_actions'; + +const taskManager = taskManagerMock.createStart(); +const ruleTypeRegistry = ruleTypeRegistryMock.create(); +const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); +const encryptedSavedObjects = encryptedSavedObjectsMock.createClient(); +const authorization = alertingAuthorizationMock.create(); +const actionsAuthorization = actionsAuthorizationMock.create(); +const internalSavedObjectsRepository = savedObjectsRepositoryMock.create(); + +describe('denormalizeActions', () => { + const kibanaVersion = 'v8.0.0'; + + const context: jest.Mocked = { + taskManager, + ruleTypeRegistry, + unsecuredSavedObjectsClient, + authorization: authorization as unknown as AlertingAuthorization, + actionsAuthorization: actionsAuthorization as unknown as ActionsAuthorization, + spaceId: 'default', + getUserName: jest.fn(), + createAPIKey: jest.fn(), + logger: loggingSystemMock.create().get(), + internalSavedObjectsRepository, + encryptedSavedObjectsClient: encryptedSavedObjects, + getActionsClient: jest.fn(), + getEventLogClient: jest.fn(), + kibanaVersion, + maxScheduledPerMinute: 10000, + minimumScheduleInterval: { value: '1m', enforce: false }, + minimumScheduleIntervalInMs: 1, + fieldsToExcludeFromPublicApi: [], + isAuthenticationTypeAPIKey: jest.fn(), + getAuthenticationAPIKey: jest.fn(), + connectorAdapterRegistry: new ConnectorAdapterRegistry(), + isSystemAction: jest.fn(), + }; + + beforeEach(() => { + getBeforeSetup(context, taskManager, ruleTypeRegistry); + }); + + test('transforms a preconfigured action correctly', async () => { + const actionsClient = (await context.getActionsClient()) as jest.Mocked; + + const action = { + id: 'test-id', + group: 'default', + uuid: 'test-uuid', + params: {}, + type: RuleActionTypes.DEFAULT, + }; + + actionsClient.isPreconfigured.mockReturnValue(true); + + actionsClient.getBulk.mockReset(); + actionsClient.getBulk.mockResolvedValue([ + { + id: 'test-id', + actionTypeId: 'action-type-id-test', + config: {}, + isMissingSecrets: false, + name: 'test connector', + isPreconfigured: true, + isDeprecated: false, + isSystemAction: false, + }, + ]); + + expect(await denormalizeActions(context, [action])).toEqual({ + actions: [ + { + actionRef: 'preconfigured:test-id', + group: 'default', + uuid: 'test-uuid', + params: {}, + actionTypeId: 'action-type-id-test', + }, + ], + references: [], + }); + }); + + test('transforms a system action correctly', async () => { + const actionsClient = (await context.getActionsClient()) as jest.Mocked; + + const action = { + id: 'test-id', + uuid: 'test-uuid', + params: {}, + type: RuleActionTypes.SYSTEM, + }; + + actionsClient.isSystemAction.mockReturnValue(true); + + actionsClient.getBulk.mockReset(); + actionsClient.getBulk.mockResolvedValue([ + { + id: 'test-id', + actionTypeId: 'action-type-id-test', + config: {}, + isMissingSecrets: false, + name: 'test connector', + isPreconfigured: false, + isDeprecated: false, + isSystemAction: true, + }, + ]); + + expect(await denormalizeActions(context, [action])).toEqual({ + actions: [ + { + actionRef: 'system_action:test-id', + uuid: 'test-uuid', + params: {}, + actionTypeId: 'action-type-id-test', + }, + ], + references: [], + }); + }); + + test('transforms a default action correctly', async () => { + const actionsClient = (await context.getActionsClient()) as jest.Mocked; + + const action = { + id: 'test-id', + group: 'default', + uuid: 'test-uuid', + params: {}, + type: RuleActionTypes.DEFAULT, + }; + + actionsClient.isPreconfigured.mockReturnValue(false); + actionsClient.isSystemAction.mockReturnValue(false); + + actionsClient.getBulk.mockReset(); + actionsClient.getBulk.mockResolvedValue([ + { + id: 'test-id', + actionTypeId: 'action-type-id-test', + config: {}, + isMissingSecrets: false, + name: 'test connector', + isPreconfigured: false, + isDeprecated: false, + isSystemAction: true, + }, + ]); + + expect(await denormalizeActions(context, [action])).toEqual({ + actions: [ + { + actionRef: 'action_0', + group: 'default', + uuid: 'test-uuid', + params: {}, + actionTypeId: 'action-type-id-test', + }, + ], + references: [ + { + id: 'test-id', + name: 'action_0', + type: 'action', + }, + ], + }); + }); + + test('does not add extra attributes to a preconfigured action', async () => { + const actionsClient = (await context.getActionsClient()) as jest.Mocked; + + const action = { + id: 'test-id', + group: 'default', + uuid: 'test-uuid', + params: {}, + foo: 'not-exists', + type: RuleActionTypes.DEFAULT, + }; + + actionsClient.isPreconfigured.mockReturnValue(true); + + actionsClient.getBulk.mockReset(); + actionsClient.getBulk.mockResolvedValue([ + { + id: 'test-id', + actionTypeId: 'action-type-id-test', + config: {}, + isMissingSecrets: false, + name: 'test connector', + isPreconfigured: true, + isDeprecated: false, + isSystemAction: false, + }, + ]); + + expect(await denormalizeActions(context, [action])).toEqual({ + actions: [ + { + actionRef: 'preconfigured:test-id', + group: 'default', + uuid: 'test-uuid', + params: {}, + actionTypeId: 'action-type-id-test', + }, + ], + references: [], + }); + }); + + test('does not add extra attributes to a system action', async () => { + const actionsClient = (await context.getActionsClient()) as jest.Mocked; + + const action = { + id: 'test-id', + group: 'not-exist', + uuid: 'test-uuid', + params: {}, + type: RuleActionTypes.SYSTEM, + }; + + actionsClient.isSystemAction.mockReturnValue(true); + + actionsClient.getBulk.mockReset(); + actionsClient.getBulk.mockResolvedValue([ + { + id: 'test-id', + actionTypeId: 'action-type-id-test', + config: {}, + isMissingSecrets: false, + name: 'test connector', + isPreconfigured: false, + isDeprecated: false, + isSystemAction: true, + }, + ]); + + expect(await denormalizeActions(context, [action])).toEqual({ + actions: [ + { + actionRef: 'system_action:test-id', + uuid: 'test-uuid', + params: {}, + actionTypeId: 'action-type-id-test', + }, + ], + references: [], + }); + }); + + test('does not add extra attributes to a default action', async () => { + const actionsClient = (await context.getActionsClient()) as jest.Mocked; + + const action = { + id: 'test-id', + group: 'default', + uuid: 'test-uuid', + params: {}, + foo: 'not-exists', + type: RuleActionTypes.DEFAULT, + }; + + actionsClient.isPreconfigured.mockReturnValue(false); + actionsClient.isSystemAction.mockReturnValue(false); + + actionsClient.getBulk.mockReset(); + actionsClient.getBulk.mockResolvedValue([ + { + id: 'test-id', + actionTypeId: 'action-type-id-test', + config: {}, + isMissingSecrets: false, + name: 'test connector', + isPreconfigured: false, + isDeprecated: false, + isSystemAction: true, + }, + ]); + + expect(await denormalizeActions(context, [action])).toEqual({ + actions: [ + { + actionRef: 'action_0', + group: 'default', + uuid: 'test-uuid', + params: {}, + actionTypeId: 'action-type-id-test', + }, + ], + references: [ + { + id: 'test-id', + name: 'action_0', + type: 'action', + }, + ], + }); + }); + + test('notify usage of action types', async () => { + const actionsClient = (await context.getActionsClient()) as jest.Mocked; + + const action = { + id: 'test-id', + group: 'default', + uuid: 'test-uuid', + params: {}, + type: RuleActionTypes.DEFAULT, + }; + + actionsClient.getBulk.mockReset(); + actionsClient.getBulk.mockResolvedValue([ + { + id: 'test-id', + actionTypeId: 'action-type-id-test', + config: {}, + isMissingSecrets: false, + name: 'test connector', + isPreconfigured: false, + isDeprecated: false, + isSystemAction: true, + }, + ]); + + await denormalizeActions(context, [action]); + + expect(actionsClient.isActionTypeEnabled).toHaveBeenCalledWith('action-type-id-test', { + notifyUsage: true, + }); + }); +}); diff --git a/x-pack/plugins/alerting/server/rules_client/lib/denormalize_actions.ts b/x-pack/plugins/alerting/server/rules_client/lib/denormalize_actions.ts index db1dca186aee9..e7d26be96b764 100644 --- a/x-pack/plugins/alerting/server/rules_client/lib/denormalize_actions.ts +++ b/x-pack/plugins/alerting/server/rules_client/lib/denormalize_actions.ts @@ -11,7 +11,12 @@ import { preconfiguredConnectorActionRefPrefix, systemConnectorActionRefPrefix, } from '../common/constants'; -import { NormalizedAlertActionWithGeneratedValues, RulesClientContext } from '../types'; +import { + NormalizedAlertActionWithGeneratedValues, + NormalizedAlertDefaultActionWithGeneratedValues, + NormalizedAlertSystemActionWithGeneratedValues, + RulesClientContext, +} from '../types'; export async function denormalizeActions( context: RulesClientContext, @@ -19,6 +24,7 @@ export async function denormalizeActions( ): Promise<{ actions: RawRule['actions']; references: SavedObjectReference[] }> { const references: SavedObjectReference[] = []; const actions: RawRule['actions'] = []; + if (alertActions.length) { const actionsClient = await context.getActionsClient(); const actionIds = [...new Set(alertActions.map((alertAction) => alertAction.id))]; @@ -29,34 +35,66 @@ export async function denormalizeActions( }); const actionTypeIds = [...new Set(actionResults.map((action) => action.actionTypeId))]; + actionTypeIds.forEach((id) => { // Notify action type usage via "isActionTypeEnabled" function actionsClient.isActionTypeEnabled(id, { notifyUsage: true }); }); - alertActions.forEach(({ id, ...alertAction }, i) => { + + /** + * Be aware that TS does not produce an error when spreading the alertAction. + * The id and the type should not be persisted to ES. + */ + alertActions.forEach(({ id, type, ...alertAction }, i) => { const actionResultValue = actionResults.find((action) => action.id === id); if (actionResultValue) { if (actionsClient.isPreconfigured(id)) { + const action = alertAction as Omit< + NormalizedAlertDefaultActionWithGeneratedValues, + 'id' | 'type' + >; + actions.push({ - ...alertAction, + group: action.group, + params: action.params, + uuid: action.uuid, + ...(action.frequency && { frequency: action.frequency }), + ...(action.alertsFilter && { alertsFilter: action.alertsFilter }), actionRef: `${preconfiguredConnectorActionRefPrefix}${id}`, actionTypeId: actionResultValue.actionTypeId, }); } else if (actionsClient.isSystemAction(id)) { + const action = alertAction as Omit< + NormalizedAlertSystemActionWithGeneratedValues, + 'id' | 'type' + >; + actions.push({ - ...alertAction, + params: action.params, + uuid: action.uuid, actionRef: `${systemConnectorActionRefPrefix}${id}`, actionTypeId: actionResultValue.actionTypeId, }); } else { + const action = alertAction as Omit< + NormalizedAlertDefaultActionWithGeneratedValues, + 'id' | 'type' + >; + const actionRef = `action_${i}`; + references.push({ id, name: actionRef, type: 'action', }); + actions.push({ - ...alertAction, + group: action.group, + params: action.params, + uuid: action.uuid, + ...(action.frequency && { frequency: action.frequency }), + ...(action.alertsFilter && { alertsFilter: action.alertsFilter }), actionRef, actionTypeId: actionResultValue.actionTypeId, }); diff --git a/x-pack/plugins/alerting/server/rules_client/lib/get_alert_from_raw.test.ts b/x-pack/plugins/alerting/server/rules_client/lib/get_alert_from_raw.test.ts new file mode 100644 index 0000000000000..9e5d15fa7181e --- /dev/null +++ b/x-pack/plugins/alerting/server/rules_client/lib/get_alert_from_raw.test.ts @@ -0,0 +1,180 @@ +/* + * 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 { ActionsAuthorization } from '@kbn/actions-plugin/server'; +import { actionsAuthorizationMock } from '@kbn/actions-plugin/server/mocks'; +import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; +import { + savedObjectsClientMock, + savedObjectsRepositoryMock, +} from '@kbn/core-saved-objects-api-server-mocks'; +import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks'; +import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks'; +import { AlertingAuthorization } from '../../authorization'; +import { alertingAuthorizationMock } from '../../authorization/alerting_authorization.mock'; +import { ConnectorAdapterRegistry } from '../../connector_adapters/connector_adapter_registry'; +import { UntypedNormalizedRuleType } from '../../rule_type_registry'; +import { ruleTypeRegistryMock } from '../../rule_type_registry.mock'; +import { RawRule, RawRuleAction, RecoveredActionGroup, RuleActionTypes } from '../../types'; +import { RulesClientContext } from '../types'; +import { getAlertFromRaw } from './get_alert_from_raw'; + +describe('getAlertFromRaw()', () => { + const taskManager = taskManagerMock.createStart(); + const ruleTypeRegistry = ruleTypeRegistryMock.create(); + const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); + const encryptedSavedObjects = encryptedSavedObjectsMock.createClient(); + const authorization = alertingAuthorizationMock.create(); + const actionsAuthorization = actionsAuthorizationMock.create(); + const internalSavedObjectsRepository = savedObjectsRepositoryMock.create(); + + const kibanaVersion = 'v8.0.0'; + const context: jest.Mocked = { + taskManager, + ruleTypeRegistry, + unsecuredSavedObjectsClient, + authorization: authorization as unknown as AlertingAuthorization, + actionsAuthorization: actionsAuthorization as unknown as ActionsAuthorization, + spaceId: 'default', + getUserName: jest.fn(), + createAPIKey: jest.fn(), + logger: loggingSystemMock.create().get(), + encryptedSavedObjectsClient: encryptedSavedObjects, + getActionsClient: jest.fn(), + getEventLogClient: jest.fn(), + kibanaVersion, + minimumScheduleInterval: { value: '1m', enforce: false }, + minimumScheduleIntervalInMs: 1, + fieldsToExcludeFromPublicApi: [], + isAuthenticationTypeAPIKey: jest.fn(), + getAuthenticationAPIKey: jest.fn(), + maxScheduledPerMinute: 10000, + internalSavedObjectsRepository, + connectorAdapterRegistry: new ConnectorAdapterRegistry(), + isSystemAction: jest.fn(), + }; + + const rawRule: RawRule = { + enabled: true, + name: 'rule-name', + tags: ['tag-1', 'tag-2'], + alertTypeId: '123', + consumer: 'rule-consumer', + legacyId: null, + schedule: { interval: '1s' }, + actions: [], + params: {}, + createdBy: null, + updatedBy: null, + createdAt: '2019-02-12T21:01:22.479Z', + updatedAt: '2019-02-12T21:01:22.479Z', + apiKey: null, + apiKeyOwner: null, + throttle: null, + notifyWhen: null, + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'unknown', + lastExecutionDate: '2020-08-20T19:23:38Z', + error: null, + warning: null, + }, + revision: 0, + }; + + const ruleType: jest.Mocked = { + id: 'test', + name: 'My test rule', + actionGroups: [{ id: 'default', name: 'Default' }, RecoveredActionGroup], + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + recoveryActionGroup: RecoveredActionGroup, + executor: jest.fn(), + producer: 'alerts', + cancelAlertsOnRuleTimeout: true, + ruleTaskTimeout: '5m', + autoRecoverAlerts: true, + validate: { + params: { validate: (params) => params }, + }, + }; + + const defaultAction: RawRuleAction = { + actionRef: 'default-action-ref', + uuid: '111', + params: { foo: 'bar' }, + group: 'default', + actionTypeId: '.test', + frequency: { notifyWhen: 'onThrottleInterval', summary: true, throttle: '1h' }, + alertsFilter: { + query: { dsl: '{test:1}', kql: 'test:1s', filters: [] }, + timeframe: { + days: [1, 2, 3], + hours: { end: '15:00', start: '00:00' }, + timezone: 'UTC', + }, + }, + }; + + const systemAction: RawRuleAction = { + actionRef: 'system-action-ref', + uuid: '111', + params: { foo: 'bar' }, + actionTypeId: '.test', + }; + + it('transforms a default action correctly', () => { + ruleTypeRegistry.get.mockReturnValue(ruleType); + + const res = getAlertFromRaw(context, '1', '.test', { ...rawRule, actions: [defaultAction] }, [ + { id: 'default-action-id', name: 'default-action-ref', type: 'test' }, + ]); + + expect(res.actions).toEqual([ + { + id: 'default-action-id', + uuid: '111', + params: { foo: 'bar' }, + group: 'default', + actionTypeId: '.test', + frequency: { notifyWhen: 'onThrottleInterval', summary: true, throttle: '1h' }, + alertsFilter: { + query: { kql: 'test:1s', filters: [] }, + timeframe: { + days: [1, 2, 3], + hours: { end: '15:00', start: '00:00' }, + timezone: 'UTC', + }, + }, + type: RuleActionTypes.DEFAULT, + }, + ]); + }); + + it('transforms a system action correctly', () => { + ruleTypeRegistry.get.mockReturnValue(ruleType); + context.isSystemAction.mockReturnValue(true); + + const res = getAlertFromRaw(context, '1', '.test', { ...rawRule, actions: [systemAction] }, [ + { id: 'system-action-id', name: 'system-action-ref', type: 'test' }, + ]); + + expect(res.actions).toEqual([ + { + actionTypeId: '.test', + id: 'system-action-id', + params: { + foo: 'bar', + }, + type: RuleActionTypes.SYSTEM, + uuid: '111', + }, + ]); + }); +}); diff --git a/x-pack/plugins/alerting/server/rules_client/lib/get_alert_from_raw.ts b/x-pack/plugins/alerting/server/rules_client/lib/get_alert_from_raw.ts index 2b4dbce71f76a..2fcbae39fc76e 100644 --- a/x-pack/plugins/alerting/server/rules_client/lib/get_alert_from_raw.ts +++ b/x-pack/plugins/alerting/server/rules_client/lib/get_alert_from_raw.ts @@ -23,8 +23,10 @@ import { } from '../../lib'; import { UntypedNormalizedRuleType } from '../../rule_type_registry'; import { getActiveScheduledSnoozes } from '../../lib/is_rule_snoozed'; -import { injectReferencesIntoActions, injectReferencesIntoParams } from '../common'; +import { injectReferencesIntoParams } from '../common'; import { RulesClientContext } from '../types'; +import { RuleDomain } from '../../application/rule/types'; +import { transformRawActionsToDomainActions } from '../../application/rule/transforms/transform_raw_actions_to_domain_actions'; export interface GetAlertFromRawParams { id: string; @@ -90,6 +92,7 @@ export function getPartialRuleFromRaw( actions, snoozeSchedule, lastRun, + isSnoozedUntil, ...partialRawRule }: Partial, references: SavedObjectReference[] | undefined, @@ -98,6 +101,8 @@ export function getPartialRuleFromRaw( includeSnoozeData: boolean = false, omitGeneratedValues: boolean = true ): PartialRule | PartialRuleWithLegacyId { + const isSystemAction = context.isSystemAction; + const snoozeScheduleDates = snoozeSchedule?.map((s) => ({ ...s, rRule: { @@ -106,15 +111,27 @@ export function getPartialRuleFromRaw( ...(s.rRule.until ? { until: new Date(s.rRule.until).toISOString() } : {}), }, })); + const includeSnoozeSchedule = snoozeSchedule !== undefined && !isEmpty(snoozeSchedule) && !excludeFromPublicApi; - const isSnoozedUntil = includeSnoozeSchedule + + const isSnoozedUntilAsDate = includeSnoozeSchedule ? getRuleSnoozeEndTime({ muteAll: partialRawRule.muteAll ?? false, snoozeSchedule, }) : null; + const includeMonitoring = monitoring && !excludeFromPublicApi; + + const ruleDomainActions: RuleDomain['actions'] = transformRawActionsToDomainActions({ + ruleId: id, + actions: actions ?? [], + references, + isSystemAction, + omitGeneratedValues, + }); + const rule: PartialRule = { id, notifyWhen, @@ -122,7 +139,7 @@ export function getPartialRuleFromRaw( // we currently only support the Interval Schedule type // Once we support additional types, this type signature will likely change schedule: schedule as IntervalSchedule, - actions: actions ? injectReferencesIntoActions(id, actions, references || []) : [], + actions: ruleDomainActions, params: injectReferencesIntoParams(id, ruleType, params, references || []) as Params, ...(excludeFromPublicApi ? {} : { snoozeSchedule: snoozeScheduleDates ?? [] }), ...(includeSnoozeData && !excludeFromPublicApi @@ -131,7 +148,7 @@ export function getPartialRuleFromRaw( snoozeSchedule, muteAll: partialRawRule.muteAll ?? false, })?.map((s) => s.id), - isSnoozedUntil, + isSnoozedUntil: isSnoozedUntilAsDate, } : {}), ...(updatedAt ? { updatedAt: new Date(updatedAt) } : {}), @@ -156,12 +173,6 @@ export function getPartialRuleFromRaw( : {}), }; - if (omitGeneratedValues) { - if (rule.actions) { - rule.actions = rule.actions.map((ruleAction) => omit(ruleAction, 'alertsFilter.query.dsl')); - } - } - // Need the `rule` object to build a URL if (!excludeFromPublicApi) { const viewInAppRelativeUrl = diff --git a/x-pack/plugins/alerting/server/rules_client/lib/siem_legacy_actions/format_legacy_actions.test.ts b/x-pack/plugins/alerting/server/rules_client/lib/siem_legacy_actions/format_legacy_actions.test.ts index 072a1e98cc3de..5e52cd8dde4b2 100644 --- a/x-pack/plugins/alerting/server/rules_client/lib/siem_legacy_actions/format_legacy_actions.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/lib/siem_legacy_actions/format_legacy_actions.test.ts @@ -92,6 +92,7 @@ describe('legacyGetBulkRuleActionsSavedObject', () => { }, }, ]; + savedObjectsClient.find.mockResolvedValue({ total: 0, per_page: 0, @@ -104,6 +105,7 @@ describe('legacyGetBulkRuleActionsSavedObject', () => { savedObjectsClient, logger, }); + expect(returnValue).toEqual({ 'alert-123': { ruleThrottle: '1d', diff --git a/x-pack/plugins/alerting/server/rules_client/lib/siem_legacy_actions/format_legacy_actions.ts b/x-pack/plugins/alerting/server/rules_client/lib/siem_legacy_actions/format_legacy_actions.ts index a0aa3286f1f6d..1b6e7740cd53f 100644 --- a/x-pack/plugins/alerting/server/rules_client/lib/siem_legacy_actions/format_legacy_actions.ts +++ b/x-pack/plugins/alerting/server/rules_client/lib/siem_legacy_actions/format_legacy_actions.ts @@ -8,7 +8,8 @@ import { chunk } from 'lodash'; import type { SavedObjectsFindOptionsReference, Logger } from '@kbn/core/server'; import pMap from 'p-map'; -import { RuleAction, Rule } from '../../../types'; +import { RuleDefaultAction } from '../../../../common'; +import { Rule } from '../../../types'; import type { RuleExecutorServices } from '../../..'; import { injectReferencesIntoActions } from '../../common'; import { transformToNotifyWhen } from './transform_to_notify_when'; @@ -30,7 +31,7 @@ interface LegacyGetBulkRuleActionsSavedObject { */ export interface LegacyActionsObj { ruleThrottle: string | null; - legacyRuleActions: RuleAction[]; + legacyRuleActions: Array & { group: string }>; } /** @@ -51,7 +52,9 @@ export const legacyGetBulkRuleActionsSavedObject = async ({ id: alertId, type: 'alert', })); + const errors: unknown[] = []; + const results = await pMap( chunk(references, 1000), async (referencesChunk) => { @@ -73,6 +76,7 @@ export const legacyGetBulkRuleActionsSavedObject = async ({ }, { concurrency: 1 } ); + const actionSavedObjects = results.flat().flatMap((r) => r.saved_objects); if (errors.length) { @@ -91,14 +95,20 @@ export const legacyGetBulkRuleActionsSavedObject = async ({ savedObject.attributes, savedObject.references ); + acc[ruleAlertIdKey] = { ruleThrottle: savedObject.attributes.ruleThrottle, legacyRuleActions: injectReferencesIntoActions( ruleAlertIdKey, legacyRawActions, savedObject.references - ) // remove uuid from action, as this uuid is not persistent - .map(({ uuid, ...action }) => action), + ) + /** + * Remove uuid from action, as this uuid is not persistent. + * TS complains about the group being undefined (system actions). + * Legacy actions will have a group. + */ + .map(({ uuid, ...action }) => action) as RuleDefaultAction[], }; } else { logger.error( diff --git a/x-pack/plugins/alerting/server/rules_client/lib/siem_legacy_actions/migrate_legacy_actions.ts b/x-pack/plugins/alerting/server/rules_client/lib/siem_legacy_actions/migrate_legacy_actions.ts index 75bcb39e522b9..d58f705825334 100644 --- a/x-pack/plugins/alerting/server/rules_client/lib/siem_legacy_actions/migrate_legacy_actions.ts +++ b/x-pack/plugins/alerting/server/rules_client/lib/siem_legacy_actions/migrate_legacy_actions.ts @@ -11,10 +11,10 @@ import { i18n } from '@kbn/i18n'; import { AlertConsumers } from '@kbn/rule-data-utils'; import type { SavedObjectReference } from '@kbn/core/server'; +import { transformRawActionsToDomainActions } from '../../../application/rule/transforms/transform_raw_actions_to_domain_actions'; import type { RulesClientContext } from '../..'; import { RawRuleAction, RawRule } from '../../../types'; import { validateActions } from '../validate_actions'; -import { injectReferencesIntoActions } from '../../common'; import { retrieveMigratedLegacyActions } from './retrieve_migrated_legacy_actions'; type MigrateLegacyActions = ( @@ -60,13 +60,22 @@ export const migrateLegacyActions: MigrateLegacyActions = async ( if (skipActionsValidation === true) { return; } + const ruleType = context.ruleTypeRegistry.get(attributes.alertTypeId); + + const transformedActions = transformRawActionsToDomainActions({ + ruleId, + actions: legacyActions, + references: legacyActionsReferences, + isSystemAction: context.isSystemAction, + }); + await validateActions(context, ruleType, { ...attributes, // set to undefined to avoid both per-actin and rule level values clashing throttle: undefined, notifyWhen: undefined, - actions: injectReferencesIntoActions(ruleId, legacyActions, legacyActionsReferences), + actions: transformedActions, }); }; diff --git a/x-pack/plugins/alerting/server/rules_client/lib/siem_legacy_actions/retrieve_migrated_legacy_actions.mock.ts b/x-pack/plugins/alerting/server/rules_client/lib/siem_legacy_actions/retrieve_migrated_legacy_actions.mock.ts index d45c86c3f05a5..1a88fbebe0a0b 100644 --- a/x-pack/plugins/alerting/server/rules_client/lib/siem_legacy_actions/retrieve_migrated_legacy_actions.mock.ts +++ b/x-pack/plugins/alerting/server/rules_client/lib/siem_legacy_actions/retrieve_migrated_legacy_actions.mock.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { RuleActionTypes } from '../../../../common'; import type { SavedObjectsFindResponse, SavedObjectsFindResult, @@ -47,6 +48,7 @@ export const legacyGetHourlyNotificationResult = ( }, actionTypeId: '.email', id: '99403909-ca9b-49ba-9d7a-7e5320e68d05', + type: RuleActionTypes.DEFAULT, }, ], throttle: null, @@ -96,6 +98,7 @@ export const legacyGetWeeklyNotificationResult = ( }, actionTypeId: '.email', id: '99403909-ca9b-49ba-9d7a-7e5320e68d05', + type: RuleActionTypes.DEFAULT, }, ], throttle: null, @@ -145,6 +148,7 @@ export const legacyGetDailyNotificationResult = ( }, actionTypeId: '.email', id: '99403909-ca9b-49ba-9d7a-7e5320e68d05', + type: RuleActionTypes.DEFAULT, }, ], throttle: null, diff --git a/x-pack/plugins/alerting/server/rules_client/lib/validate_actions.test.ts b/x-pack/plugins/alerting/server/rules_client/lib/validate_actions.test.ts index 84f0a89e72c01..ac09a788b528b 100644 --- a/x-pack/plugins/alerting/server/rules_client/lib/validate_actions.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/lib/validate_actions.test.ts @@ -7,8 +7,14 @@ import { validateActions, ValidateActionsData } from './validate_actions'; import { UntypedNormalizedRuleType } from '../../rule_type_registry'; -import { AlertsFilter, RecoveredActionGroup, RuleNotifyWhen } from '../../../common'; -import { RulesClientContext } from '..'; +import { + AlertsFilter, + RecoveredActionGroup, + RuleActionTypes, + RuleDefaultAction, + RuleNotifyWhen, +} from '../../../common'; +import { NormalizedAlertAction, RulesClientContext } from '..'; describe('validateActions', () => { const loggerErrorMock = jest.fn(); @@ -34,25 +40,33 @@ describe('validateActions', () => { }, }; + const defaultAction: NormalizedAlertAction = { + uuid: '111', + group: 'default', + id: '1', + params: {}, + frequency: { + summary: false, + notifyWhen: RuleNotifyWhen.ACTIVE, + throttle: null, + }, + alertsFilter: { + query: { kql: 'test:1', filters: [] }, + timeframe: { days: [1], hours: { start: '10:00', end: '17:00' }, timezone: 'UTC' }, + }, + type: RuleActionTypes.DEFAULT, + }; + + const systemAction: NormalizedAlertAction = { + uuid: '111', + id: '1', + params: {}, + type: RuleActionTypes.SYSTEM, + }; + const data = { schedule: { interval: '1m' }, - actions: [ - { - uuid: '111', - group: 'default', - id: '1', - params: {}, - frequency: { - summary: false, - notifyWhen: RuleNotifyWhen.ACTIVE, - throttle: null, - }, - alertsFilter: { - query: { kql: 'test:1', filters: [] }, - timeframe: { days: [1], hours: { start: '10:00', end: '17:00' }, timezone: 'UTC' }, - }, - }, - ], + actions: [defaultAction], } as unknown as ValidateActionsData; const context = { @@ -89,6 +103,22 @@ describe('validateActions', () => { ); }); + it('should return error message if actions have duplicated uuid and there is a system action', async () => { + await expect( + validateActions( + context as unknown as RulesClientContext, + ruleType, + { + ...data, + actions: [defaultAction, systemAction], + }, + false + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + '"Failed to validate actions due to the following error: Actions have duplicated UUIDs"' + ); + }); + it('should return error message if any action have isMissingSecrets', async () => { getBulkMock.mockResolvedValue([{ isMissingSecrets: true, name: 'test name' }]); await expect( @@ -103,7 +133,7 @@ describe('validateActions', () => { validateActions( context as unknown as RulesClientContext, ruleType, - { ...data, actions: [{ ...data.actions[0], group: 'invalid' }] }, + { ...data, actions: [{ ...data.actions[0], group: 'invalid' } as RuleDefaultAction] }, false ) ).rejects.toThrowErrorMatchingInlineSnapshot( @@ -142,7 +172,7 @@ describe('validateActions', () => { validateActions( context as unknown as RulesClientContext, ruleType, - { ...data, actions: [{ ...data.actions[0], frequency: undefined }] }, + { ...data, actions: [{ ...data.actions[0], frequency: undefined } as RuleDefaultAction] }, false ) ).rejects.toThrowErrorMatchingInlineSnapshot( @@ -161,7 +191,7 @@ describe('validateActions', () => { { ...data.actions[0], frequency: { summary: false, notifyWhen: 'onThrottleInterval', throttle: '1s' }, - }, + } as RuleDefaultAction, ], }, false @@ -182,7 +212,7 @@ describe('validateActions', () => { { ...data.actions[0], alertsFilter: {} as AlertsFilter, - }, + } as RuleDefaultAction, ], }, false @@ -206,7 +236,7 @@ describe('validateActions', () => { query: { kql: 'test:1', filters: [] }, timeframe: { days: [1], hours: { start: '30:00', end: '17:00' }, timezone: 'UTC' }, }, - }, + } as NormalizedAlertAction, ], }, false @@ -253,6 +283,7 @@ describe('validateActions', () => { '"Failed to validate actions due to the following error: Action\'s alertsFilter timeframe has missing fields: days, hours or timezone: 111"' ); }); + it('should return error message if any action has alertsFilter timeframe has invalid days', async () => { await expect( validateActions( @@ -281,4 +312,18 @@ describe('validateActions', () => { '"Failed to validate actions due to the following error: Action\'s alertsFilter days has invalid values: (111:[0,8]) "' ); }); + + it('should not throw an error on system actions that do not contain properties like frequency or group', async () => { + const res = await validateActions( + context as unknown as RulesClientContext, + ruleType, + { + ...data, + actions: [systemAction], + }, + false + ); + + expect(res).toBe(undefined); + }); }); diff --git a/x-pack/plugins/alerting/server/rules_client/lib/validate_actions.ts b/x-pack/plugins/alerting/server/rules_client/lib/validate_actions.ts index 5a1e9911b5fcb..52c9075b6fa18 100644 --- a/x-pack/plugins/alerting/server/rules_client/lib/validate_actions.ts +++ b/x-pack/plugins/alerting/server/rules_client/lib/validate_actions.ts @@ -9,7 +9,7 @@ import Boom from '@hapi/boom'; import { map } from 'lodash'; import { i18n } from '@kbn/i18n'; import { validateHours } from '../../routes/lib/validate_hours'; -import { RawRule, RuleNotifyWhen } from '../../types'; +import { RuleDefaultAction, RawRule, RuleActionTypes, RuleNotifyWhen } from '../../types'; import { UntypedNormalizedRuleType } from '../../rule_type_registry'; import { NormalizedAlertAction } from '../types'; import { RulesClientContext } from '../types'; @@ -43,9 +43,18 @@ export async function validateActions( ); } + /** + * All the validation below are not applicable for system actions + * as users are not allowed to set fields like the group + * or the throttle + */ + const actionsWithoutSystemActions = actions.filter( + (action): action is RuleDefaultAction => action.type !== RuleActionTypes.SYSTEM + ); + // check for actions using connectors with missing secrets const actionsClient = await context.getActionsClient(); - const actionIds = [...new Set(actions.map((action) => action.id))]; + const actionIds = [...new Set(actionsWithoutSystemActions.map((action) => action.id))]; const actionResults = (await actionsClient.getBulk({ ids: actionIds, throwIfSystemAction: false })) || []; @@ -76,7 +85,7 @@ export async function validateActions( } // check for actions with invalid action groups const { actionGroups: alertTypeActionGroups } = ruleType; - const usedAlertActionGroups = actions.map((action) => action.group); + const usedAlertActionGroups = actionsWithoutSystemActions.map((action) => action.group); const availableAlertTypeActionGroups = new Set(map(alertTypeActionGroups, 'id')); const invalidActionGroups = usedAlertActionGroups.filter( (group) => !availableAlertTypeActionGroups.has(group) @@ -94,7 +103,10 @@ export async function validateActions( // check for actions using frequency params if the rule has rule-level frequency params defined if (hasRuleLevelNotifyWhen || hasRuleLevelThrottle) { - const actionsWithFrequency = actions.filter((action) => Boolean(action.frequency)); + const actionsWithFrequency = actionsWithoutSystemActions.filter((action) => + Boolean(action.frequency) + ); + if (actionsWithFrequency.length) { errors.push( i18n.translate('xpack.alerting.rulesClient.validateActions.mixAndMatchFreqParams', { @@ -107,7 +119,10 @@ export async function validateActions( ); } } else { - const actionsWithoutFrequency = actions.filter((action) => !action.frequency); + const actionsWithoutFrequency = actionsWithoutSystemActions.filter( + (action) => !action.frequency + ); + if (actionsWithoutFrequency.length) { errors.push( i18n.translate('xpack.alerting.rulesClient.validateActions.notAllActionsWithFreq', { @@ -128,7 +143,7 @@ export async function validateActions( const actionsWithInvalidDays = []; const actionsWithAlertsFilterWithoutAlertsMapping = []; - for (const action of actions) { + for (const action of actionsWithoutSystemActions) { const { alertsFilter } = action; // check for actions throttled shorter than the rule schedule diff --git a/x-pack/plugins/alerting/server/rules_client/methods/update.ts b/x-pack/plugins/alerting/server/rules_client/methods/update.ts index 925f128f0b8b3..2b9276173fdfd 100644 --- a/x-pack/plugins/alerting/server/rules_client/methods/update.ts +++ b/x-pack/plugins/alerting/server/rules_client/methods/update.ts @@ -14,6 +14,7 @@ import { RuleTypeParams, RuleNotifyWhenType, IntervalSchedule, + RuleActionTypes, } from '../../types'; import { validateRuleTypeParams, getRuleNotifyWhenType } from '../../lib'; import { WriteOperations, AlertingAuthorizationEntity } from '../../authorization'; @@ -22,7 +23,7 @@ import { retryIfConflicts } from '../../lib/retry_if_conflicts'; import { bulkMarkApiKeysForInvalidation } from '../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation'; import { ruleAuditEvent, RuleAuditAction } from '../common/audit_events'; import { getMappedParams } from '../common/mapped_params_utils'; -import { NormalizedAlertAction, RulesClientContext } from '../types'; +import { NormalizedAlertAction, NormalizedSystemAction, RulesClientContext } from '../types'; import { validateActions, extractReferences, @@ -34,6 +35,7 @@ import { migrateLegacyActions, } from '../lib'; import { validateScheduleLimit } from '../../application/rule/methods/get_schedule_frequency'; +import { validateSystemActions } from '../../lib/validate_system_actions'; type ShouldIncrementRevision = (params?: RuleTypeParams) => boolean; @@ -56,6 +58,13 @@ export async function update( context: RulesClientContext, { id, data, allowMissingConnectorSecrets, shouldIncrementRevision }: UpdateOptions ): Promise> { + /** + * TODO: Remove when the update method is versioned. + * Use a schema instead. + */ + + validateActionsSchema(data.actions); + return await retryIfConflicts( context.logger, `rulesClient.update('${id}')`, @@ -201,9 +210,20 @@ async function updateAlert( const data = { ...initialData, actions: addGeneratedActionValues(initialData.actions) }; const ruleType = context.ruleTypeRegistry.get(attributes.alertTypeId); + const actionsClient = await context.getActionsClient(); + const systemActions = initialData.actions.filter( + (action): action is NormalizedSystemAction => action.type === RuleActionTypes.SYSTEM + ); // Validate const validatedAlertTypeParams = validateRuleTypeParams(data.params, ruleType.validate.params); + + await validateSystemActions({ + actionsClient, + connectorAdapterRegistry: context.connectorAdapterRegistry, + systemActions, + }); + await validateActions(context, ruleType, data, allowMissingConnectorSecrets); // Throw error if schedule interval is less than the minimum and we are enforcing it @@ -310,3 +330,32 @@ async function updateAlert( true ); } + +/** + * TODO: Remove when the update method is versioned. + * Use a schema instead. + */ +const validateActionsSchema = (actions?: UpdateOptions<{}>['data']['actions']) => { + try { + if (actions == null || actions.length === 0) { + return; + } + + for (const action of actions) { + if (action.type === RuleActionTypes.SYSTEM) { + // @ts-expect-error: properties are not part of system actions + if (action.frequency || action.alertsFilter) { + throw new Error('frequency or alertsFilter are not allowed for system actions'); + } + } + + if (action.type === RuleActionTypes.DEFAULT) { + if (action.group == null) { + throw new Error('group is required for alert actions'); + } + } + } + } catch (error) { + throw Boom.badRequest(`Error validating update actions: ${error.message}`); + } +}; diff --git a/x-pack/plugins/alerting/server/rules_client/tests/aggregate.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/aggregate.test.ts index 5c6d544c90c44..83c14b2ffc952 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/aggregate.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/aggregate.test.ts @@ -61,6 +61,7 @@ const rulesClientParams: jest.Mocked = { isAuthenticationTypeAPIKey: jest.fn(), getAuthenticationAPIKey: jest.fn(), connectorAdapterRegistry: new ConnectorAdapterRegistry(), + isSystemAction: jest.fn(), }; beforeEach(() => { diff --git a/x-pack/plugins/alerting/server/rules_client/tests/bulk_delete.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/bulk_delete.test.ts index 627103eca6663..a6278ee8d9e28 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/bulk_delete.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/bulk_delete.test.ts @@ -80,6 +80,7 @@ const rulesClientParams: jest.Mocked = { isAuthenticationTypeAPIKey: jest.fn(), getAuthenticationAPIKey: jest.fn(), connectorAdapterRegistry: new ConnectorAdapterRegistry(), + isSystemAction: jest.fn(), }; const getBulkOperationStatusErrorResponse = (statusCode: number) => ({ diff --git a/x-pack/plugins/alerting/server/rules_client/tests/bulk_disable.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/bulk_disable.test.ts index 7368f2bdcc9c8..c2ed276ea2804 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/bulk_disable.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/bulk_disable.test.ts @@ -89,6 +89,7 @@ const rulesClientParams: jest.Mocked = { isAuthenticationTypeAPIKey: jest.fn(), getAuthenticationAPIKey: jest.fn(), connectorAdapterRegistry: new ConnectorAdapterRegistry(), + isSystemAction: jest.fn(), }; beforeEach(() => { diff --git a/x-pack/plugins/alerting/server/rules_client/tests/bulk_enable.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/bulk_enable.test.ts index 0a6de52c9fc4e..a54dacabba8e4 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/bulk_enable.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/bulk_enable.test.ts @@ -84,6 +84,7 @@ const rulesClientParams: jest.Mocked = { isAuthenticationTypeAPIKey: jest.fn(), getAuthenticationAPIKey: jest.fn(), connectorAdapterRegistry: new ConnectorAdapterRegistry(), + isSystemAction: jest.fn(), }; beforeEach(() => { diff --git a/x-pack/plugins/alerting/server/rules_client/tests/clear_expired_snoozes.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/clear_expired_snoozes.test.ts index 466185f5bc99e..e2a05ba59c00d 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/clear_expired_snoozes.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/clear_expired_snoozes.test.ts @@ -71,6 +71,7 @@ const rulesClientParams: jest.Mocked = { isAuthenticationTypeAPIKey: jest.fn(), getAuthenticationAPIKey: jest.fn(), connectorAdapterRegistry: new ConnectorAdapterRegistry(), + isSystemAction: jest.fn(), }; describe('clearExpiredSnoozes()', () => { diff --git a/x-pack/plugins/alerting/server/rules_client/tests/delete.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/delete.test.ts index 7cc97cbcc6b64..6c3cef9ea804a 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/delete.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/delete.test.ts @@ -73,6 +73,7 @@ const rulesClientParams: jest.Mocked = { isAuthenticationTypeAPIKey: jest.fn(), getAuthenticationAPIKey: jest.fn(), connectorAdapterRegistry: new ConnectorAdapterRegistry(), + isSystemAction: jest.fn(), }; beforeEach(() => { diff --git a/x-pack/plugins/alerting/server/rules_client/tests/disable.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/disable.test.ts index 8f98f8680c566..e14e4c9fcced9 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/disable.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/disable.test.ts @@ -75,6 +75,7 @@ const rulesClientParams: jest.Mocked = { isAuthenticationTypeAPIKey: jest.fn(), getAuthenticationAPIKey: jest.fn(), connectorAdapterRegistry: new ConnectorAdapterRegistry(), + isSystemAction: jest.fn(), }; beforeEach(() => { diff --git a/x-pack/plugins/alerting/server/rules_client/tests/enable.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/enable.test.ts index 707876a97e583..62adff124483b 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/enable.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/enable.test.ts @@ -72,6 +72,7 @@ const rulesClientParams: jest.Mocked = { isAuthenticationTypeAPIKey: jest.fn(), getAuthenticationAPIKey: jest.fn(), connectorAdapterRegistry: new ConnectorAdapterRegistry(), + isSystemAction: jest.fn(), }; setGlobalDate(); diff --git a/x-pack/plugins/alerting/server/rules_client/tests/find.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/find.test.ts index 877e8a2e9a148..53e363794b9ee 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/find.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/find.test.ts @@ -65,6 +65,7 @@ const rulesClientParams: jest.Mocked = { isAuthenticationTypeAPIKey: jest.fn(), getAuthenticationAPIKey: jest.fn(), connectorAdapterRegistry: new ConnectorAdapterRegistry(), + isSystemAction: jest.fn(), }; beforeEach(() => { @@ -123,6 +124,8 @@ describe('find()', () => { params: { foo: true, }, + actionTypeId: '.test', + uuid: 'test-uuid', }, ], }, @@ -169,11 +172,14 @@ describe('find()', () => { Object { "actions": Array [ Object { + "actionTypeId": ".test", "group": "default", "id": "1", "params": Object { "foo": true, }, + "type": "default", + "uuid": "test-uuid", }, ], "alertTypeId": "myType", @@ -234,6 +240,8 @@ describe('find()', () => { params: { foo: true, }, + actionTypeId: '.test', + uuid: 'test-uuid', }, { group: 'default', @@ -241,6 +249,8 @@ describe('find()', () => { params: { foo: true, }, + actionTypeId: '.test', + uuid: 'test-uuid-2', }, ], }, @@ -263,18 +273,24 @@ describe('find()', () => { Object { "actions": Array [ Object { + "actionTypeId": ".test", "group": "default", "id": "1", "params": Object { "foo": true, }, + "type": "default", + "uuid": "test-uuid", }, Object { + "actionTypeId": ".test", "group": "default", "id": "preconfigured", "params": Object { "foo": true, }, + "type": "default", + "uuid": "test-uuid-2", }, ], "alertTypeId": "myType", @@ -335,11 +351,15 @@ describe('find()', () => { params: { foo: true, }, + actionTypeId: '.test', + uuid: 'test-uuid', }, { group: 'default', actionRef: 'system_action:system_action-id', params: {}, + actionTypeId: '.system-action', + uuid: 'test-uuid-2', }, ], }, @@ -362,16 +382,22 @@ describe('find()', () => { Object { "actions": Array [ Object { + "actionTypeId": ".test", "group": "default", "id": "1", "params": Object { "foo": true, }, + "type": "default", + "uuid": "test-uuid", }, Object { + "actionTypeId": ".system-action", "group": "default", "id": "system_action-id", "params": Object {}, + "type": "default", + "uuid": "test-uuid-2", }, ], "alertTypeId": "myType", @@ -531,6 +557,8 @@ describe('find()', () => { params: { foo: true, }, + actionTypeId: '.test', + uuid: 'test-uuid', }, ], }, @@ -563,6 +591,8 @@ describe('find()', () => { params: { foo: true, }, + actionTypeId: '.test', + uuid: 'test-uuid', }, ], }, @@ -600,11 +630,14 @@ describe('find()', () => { Object { "actions": Array [ Object { + "actionTypeId": ".test", "group": "default", "id": "1", "params": Object { "foo": true, }, + "type": "default", + "uuid": "test-uuid", }, ], "alertTypeId": "myType", @@ -623,11 +656,14 @@ describe('find()', () => { Object { "actions": Array [ Object { + "actionTypeId": ".test", "group": "default", "id": "1", "params": Object { "foo": true, }, + "type": "default", + "uuid": "test-uuid", }, ], "alertTypeId": "123", @@ -739,6 +775,8 @@ describe('find()', () => { params: { foo: true, }, + actionTypeId: '.test', + uuid: 'test-uuid', }, ], }, @@ -771,6 +809,8 @@ describe('find()', () => { params: { foo: true, }, + actionTypeId: '.test', + uuid: 'test-uuid', }, ], }, diff --git a/x-pack/plugins/alerting/server/rules_client/tests/get.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/get.test.ts index 61fb8f175f4be..7781e7301c678 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/get.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/get.test.ts @@ -62,6 +62,7 @@ const rulesClientParams: jest.Mocked = { isAuthenticationTypeAPIKey: jest.fn(), getAuthenticationAPIKey: jest.fn(), connectorAdapterRegistry: new ConnectorAdapterRegistry(), + isSystemAction: jest.fn(), }; beforeEach(() => { @@ -92,6 +93,8 @@ describe('get()', () => { params: { foo: true, }, + actionTypeId: '.test', + uuid: 'test-uuid', }, ], notifyWhen: 'onActiveAlert', @@ -109,11 +112,14 @@ describe('get()', () => { Object { "actions": Array [ Object { + "actionTypeId": ".test", "group": "default", "id": "1", "params": Object { "foo": true, }, + "type": "default", + "uuid": "test-uuid", }, ], "alertTypeId": "123", @@ -159,6 +165,8 @@ describe('get()', () => { params: { foo: true, }, + actionTypeId: '.test', + uuid: 'test-uuid', }, { group: 'default', @@ -166,6 +174,8 @@ describe('get()', () => { params: { foo: true, }, + actionTypeId: '.test', + uuid: 'test-uuid-2', }, ], notifyWhen: 'onActiveAlert', @@ -183,18 +193,24 @@ describe('get()', () => { Object { "actions": Array [ Object { + "actionTypeId": ".test", "group": "default", "id": "1", "params": Object { "foo": true, }, + "type": "default", + "uuid": "test-uuid", }, Object { + "actionTypeId": ".test", "group": "default", "id": "preconfigured", "params": Object { "foo": true, }, + "type": "default", + "uuid": "test-uuid-2", }, ], "alertTypeId": "123", @@ -240,11 +256,15 @@ describe('get()', () => { params: { foo: true, }, + actionTypeId: '.test', + uuid: 'test-uuid', }, { group: 'default', actionRef: 'system_action:system_action-id', params: {}, + actionTypeId: '.test-system-action', + uuid: 'test-uuid-2', }, ], notifyWhen: 'onActiveAlert', @@ -262,16 +282,22 @@ describe('get()', () => { Object { "actions": Array [ Object { + "actionTypeId": ".test", "group": "default", "id": "1", "params": Object { "foo": true, }, + "type": "default", + "uuid": "test-uuid", }, Object { + "actionTypeId": ".test-system-action", "group": "default", "id": "system_action-id", "params": Object {}, + "type": "default", + "uuid": "test-uuid-2", }, ], "alertTypeId": "123", @@ -342,6 +368,8 @@ describe('get()', () => { params: { foo: true, }, + actionTypeId: '.test', + uuid: 'test-uuid', }, ], notifyWhen: 'onActiveAlert', @@ -372,11 +400,14 @@ describe('get()', () => { Object { "actions": Array [ Object { + "actionTypeId": ".test", "group": "default", "id": "1", "params": Object { "foo": true, }, + "type": "default", + "uuid": "test-uuid", }, ], "alertTypeId": "123", @@ -414,6 +445,8 @@ describe('get()', () => { params: { foo: true, }, + actionTypeId: '.test', + uuid: 'test-uuid', }, ], }, @@ -468,6 +501,8 @@ describe('get()', () => { params: { foo: true, }, + actionTypeId: '.test', + uuid: 'test-uuid', }, ], notifyWhen: 'onActiveAlert', @@ -509,6 +544,8 @@ describe('get()', () => { params: { foo: true, }, + actionTypeId: '.test', + uuid: 'test-uuid', }, ], }, @@ -629,6 +666,8 @@ describe('get()', () => { params: { foo: true, }, + actionTypeId: '.test', + uuid: 'test-uuid', }, ], notifyWhen: 'onActiveAlert', diff --git a/x-pack/plugins/alerting/server/rules_client/tests/get_action_error_log.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/get_action_error_log.test.ts index 2e0627227a8ca..c90eaf4ecf68b 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/get_action_error_log.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/get_action_error_log.test.ts @@ -61,6 +61,7 @@ const rulesClientParams: jest.Mocked = { isAuthenticationTypeAPIKey: jest.fn(), getAuthenticationAPIKey: jest.fn(), connectorAdapterRegistry: new ConnectorAdapterRegistry(), + isSystemAction: jest.fn(), }; beforeEach(() => { diff --git a/x-pack/plugins/alerting/server/rules_client/tests/get_alert_state.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/get_alert_state.test.ts index 937e7d7a597a5..74f4ec5fb3b63 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/get_alert_state.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/get_alert_state.test.ts @@ -53,6 +53,7 @@ const rulesClientParams: jest.Mocked = { isAuthenticationTypeAPIKey: jest.fn(), getAuthenticationAPIKey: jest.fn(), connectorAdapterRegistry: new ConnectorAdapterRegistry(), + isSystemAction: jest.fn(), }; beforeEach(() => { 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 78c811dc64f34..fca9ae3c9c680 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 @@ -59,6 +59,7 @@ const rulesClientParams: jest.Mocked = { isAuthenticationTypeAPIKey: jest.fn(), getAuthenticationAPIKey: jest.fn(), connectorAdapterRegistry: new ConnectorAdapterRegistry(), + isSystemAction: jest.fn(), }; beforeEach(() => { diff --git a/x-pack/plugins/alerting/server/rules_client/tests/get_execution_log.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/get_execution_log.test.ts index ee8d5d9b7323c..f80f2286a7ab8 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/get_execution_log.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/get_execution_log.test.ts @@ -62,6 +62,7 @@ const rulesClientParams: jest.Mocked = { isAuthenticationTypeAPIKey: jest.fn(), getAuthenticationAPIKey: jest.fn(), connectorAdapterRegistry: new ConnectorAdapterRegistry(), + isSystemAction: jest.fn(), }; beforeEach(() => { diff --git a/x-pack/plugins/alerting/server/rules_client/tests/get_tags.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/get_tags.test.ts index 6b63f9d4366f1..45c7908a467cb 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/get_tags.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/get_tags.test.ts @@ -56,6 +56,7 @@ const rulesClientParams: jest.Mocked = { isAuthenticationTypeAPIKey: jest.fn(), getAuthenticationAPIKey: jest.fn(), connectorAdapterRegistry: new ConnectorAdapterRegistry(), + isSystemAction: jest.fn(), }; const listedTypes = new Set([ diff --git a/x-pack/plugins/alerting/server/rules_client/tests/list_rule_types.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/list_rule_types.test.ts index 6df40c08b5fa8..046073d971382 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/list_rule_types.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/list_rule_types.test.ts @@ -57,6 +57,7 @@ const rulesClientParams: jest.Mocked = { isAuthenticationTypeAPIKey: jest.fn(), getAuthenticationAPIKey: jest.fn(), connectorAdapterRegistry: new ConnectorAdapterRegistry(), + isSystemAction: jest.fn(), }; beforeEach(() => { diff --git a/x-pack/plugins/alerting/server/rules_client/tests/mute_all.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/mute_all.test.ts index 98724d87f2395..adb904246d2dc 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/mute_all.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/mute_all.test.ts @@ -53,6 +53,7 @@ const rulesClientParams: jest.Mocked = { isAuthenticationTypeAPIKey: jest.fn(), getAuthenticationAPIKey: jest.fn(), connectorAdapterRegistry: new ConnectorAdapterRegistry(), + isSystemAction: jest.fn(), }; beforeEach(() => { diff --git a/x-pack/plugins/alerting/server/rules_client/tests/mute_instance.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/mute_instance.test.ts index bc2cee981fed5..d6638fcd67534 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/mute_instance.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/mute_instance.test.ts @@ -53,6 +53,7 @@ const rulesClientParams: jest.Mocked = { isAuthenticationTypeAPIKey: jest.fn(), getAuthenticationAPIKey: jest.fn(), connectorAdapterRegistry: new ConnectorAdapterRegistry(), + isSystemAction: jest.fn(), }; beforeEach(() => { diff --git a/x-pack/plugins/alerting/server/rules_client/tests/resolve.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/resolve.test.ts index edb96bad51833..ec6eb03756ced 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/resolve.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/resolve.test.ts @@ -62,6 +62,7 @@ const rulesClientParams: jest.Mocked = { isAuthenticationTypeAPIKey: jest.fn(), getAuthenticationAPIKey: jest.fn(), connectorAdapterRegistry: new ConnectorAdapterRegistry(), + isSystemAction: jest.fn(), }; beforeEach(() => { @@ -93,6 +94,8 @@ describe('resolve()', () => { params: { foo: true, }, + actionTypeId: '.test', + uuid: 'test-uuid', }, ], notifyWhen: 'onActiveAlert', @@ -108,16 +111,21 @@ describe('resolve()', () => { outcome: 'aliasMatch', alias_target_id: '2', }); + const result = await rulesClient.resolve({ id: '1' }); + expect(result).toMatchInlineSnapshot(` Object { "actions": Array [ Object { + "actionTypeId": ".test", "group": "default", "id": "1", "params": Object { "foo": true, }, + "type": "default", + "uuid": "test-uuid", }, ], "alertTypeId": "123", @@ -167,6 +175,8 @@ describe('resolve()', () => { params: { foo: true, }, + actionTypeId: '.test', + uuid: 'test-uuid', }, ], notifyWhen: 'onActiveAlert', @@ -182,16 +192,21 @@ describe('resolve()', () => { outcome: 'aliasMatch', alias_target_id: '2', }); + const result = await rulesClient.resolve({ id: '1', includeLegacyId: true }); + expect(result).toMatchInlineSnapshot(` Object { "actions": Array [ Object { + "actionTypeId": ".test", "group": "default", "id": "1", "params": Object { "foo": true, }, + "type": "default", + "uuid": "test-uuid", }, ], "alertTypeId": "123", @@ -253,6 +268,8 @@ describe('resolve()', () => { params: { foo: true, }, + actionTypeId: '.test', + uuid: 'test-uuid', }, ], notifyWhen: 'onActiveAlert', @@ -268,6 +285,7 @@ describe('resolve()', () => { outcome: 'aliasMatch', alias_target_id: '2', }); + const result = await rulesClient.resolve({ id: '1', includeSnoozeData: true }); expect(result.isSnoozedUntil).toBeTruthy(); }); @@ -318,6 +336,8 @@ describe('resolve()', () => { params: { foo: true, }, + actionTypeId: '.test', + uuid: 'test-uuid', }, ], notifyWhen: 'onActiveAlert', @@ -338,6 +358,7 @@ describe('resolve()', () => { outcome: 'aliasMatch', alias_target_id: '2', }); + const result = await rulesClient.resolve({ id: '1' }); expect(injectReferencesFn).toHaveBeenCalledWith( @@ -347,15 +368,19 @@ describe('resolve()', () => { }, [{ id: '9', name: 'soRef_0', type: 'someSavedObjectType' }] ); + expect(result).toMatchInlineSnapshot(` Object { "actions": Array [ Object { + "actionTypeId": ".test", "group": "default", "id": "1", "params": Object { "foo": true, }, + "type": "default", + "uuid": "test-uuid", }, ], "alertTypeId": "123", @@ -396,6 +421,8 @@ describe('resolve()', () => { params: { foo: true, }, + actionTypeId: '.test', + uuid: 'test-uuid', }, ], }, @@ -404,6 +431,7 @@ describe('resolve()', () => { outcome: 'aliasMatch', alias_target_id: '2', }); + await expect(rulesClient.resolve({ id: '1' })).rejects.toThrowErrorMatchingInlineSnapshot( `"Action reference \\"action_0\\" not found in alert id: 1"` ); @@ -413,6 +441,7 @@ describe('resolve()', () => { const injectReferencesFn = jest.fn().mockImplementation(() => { throw new Error('something went wrong!'); }); + ruleTypeRegistry.get.mockImplementation(() => ({ id: '123', name: 'Test', @@ -433,6 +462,7 @@ describe('resolve()', () => { params: { validate: (params) => params }, }, })); + const rulesClient = new RulesClient(rulesClientParams); unsecuredSavedObjectsClient.resolve.mockResolvedValueOnce({ saved_object: { @@ -454,6 +484,8 @@ describe('resolve()', () => { params: { foo: true, }, + actionTypeId: '.test', + uuid: 'test-uuid', }, ], notifyWhen: 'onActiveAlert', @@ -474,6 +506,7 @@ describe('resolve()', () => { outcome: 'aliasMatch', alias_target_id: '2', }); + await expect(rulesClient.resolve({ id: '1' })).rejects.toThrowErrorMatchingInlineSnapshot( `"Error injecting reference into rule params for rule id 1 - something went wrong!"` ); @@ -499,6 +532,8 @@ describe('resolve()', () => { params: { foo: true, }, + actionTypeId: '.test', + uuid: 'test-uuid', }, ], }, @@ -626,6 +661,8 @@ describe('resolve()', () => { params: { foo: true, }, + actionTypeId: '.test', + uuid: 'test-uuid', }, ], notifyWhen: 'onActiveAlert', @@ -652,6 +689,7 @@ describe('resolve()', () => { outcome: 'aliasMatch', alias_target_id: '2', }); + (formatLegacyActions as jest.Mock).mockResolvedValue([ { id: 'migrated_rule_mock', diff --git a/x-pack/plugins/alerting/server/rules_client/tests/run_soon.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/run_soon.test.ts index b9c91f456097a..bae19dddab2d5 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/run_soon.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/run_soon.test.ts @@ -55,6 +55,7 @@ const rulesClientParams: jest.Mocked = { isAuthenticationTypeAPIKey: jest.fn(), getAuthenticationAPIKey: jest.fn(), connectorAdapterRegistry: new ConnectorAdapterRegistry(), + isSystemAction: jest.fn(), }; setGlobalDate(); diff --git a/x-pack/plugins/alerting/server/rules_client/tests/unmute_all.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/unmute_all.test.ts index 246c4ce3959a0..98c292aaf63f6 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/unmute_all.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/unmute_all.test.ts @@ -53,6 +53,7 @@ const rulesClientParams: jest.Mocked = { isAuthenticationTypeAPIKey: jest.fn(), getAuthenticationAPIKey: jest.fn(), connectorAdapterRegistry: new ConnectorAdapterRegistry(), + isSystemAction: jest.fn(), }; beforeEach(() => { diff --git a/x-pack/plugins/alerting/server/rules_client/tests/unmute_instance.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/unmute_instance.test.ts index 0de1c3f74941c..c1500d828738e 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/unmute_instance.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/unmute_instance.test.ts @@ -53,6 +53,7 @@ const rulesClientParams: jest.Mocked = { isAuthenticationTypeAPIKey: jest.fn(), getAuthenticationAPIKey: jest.fn(), connectorAdapterRegistry: new ConnectorAdapterRegistry(), + isSystemAction: jest.fn(), }; beforeEach(() => { diff --git a/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts index 2355f851d2b9e..9bb5c6876a669 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts @@ -18,7 +18,7 @@ import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks'; import { ruleTypeRegistryMock } from '../../rule_type_registry.mock'; import { alertingAuthorizationMock } from '../../authorization/alerting_authorization.mock'; import { IntervalSchedule, RuleNotifyWhen } from '../../types'; -import { RecoveredActionGroup } from '../../../common'; +import { RecoveredActionGroup, RuleActionTypes } from '../../../common'; import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks'; import { actionsAuthorizationMock } from '@kbn/actions-plugin/server/mocks'; import { AlertingAuthorization } from '../../authorization/alerting_authorization'; @@ -29,6 +29,7 @@ import { getBeforeSetup, setGlobalDate } from './lib'; import { bulkMarkApiKeysForInvalidation } from '../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation'; import { migrateLegacyActions } from '../lib'; import { ConnectorAdapterRegistry } from '../../connector_adapters/connector_adapter_registry'; +import { RuleDomain } from '../../application/rule/types'; jest.mock('../lib/siem_legacy_actions/migrate_legacy_actions', () => { return { @@ -92,6 +93,7 @@ const rulesClientParams: jest.Mocked = { isAuthenticationTypeAPIKey: jest.fn(), getAuthenticationAPIKey: jest.fn(), connectorAdapterRegistry: new ConnectorAdapterRegistry(), + isSystemAction: jest.fn(), }; beforeEach(() => { @@ -130,6 +132,8 @@ describe('update()', () => { notifyWhen: RuleNotifyWhen.CHANGE, throttle: null, }, + uuid: '123', + type: RuleActionTypes.DEFAULT, }, ], }, @@ -194,6 +198,10 @@ describe('update()', () => { resultedActions: [], resultedReferences: [], }); + + actionsClient.isSystemAction.mockImplementation((id: string) => id === 'system_action-id'); + // @ts-expect-error: read-only property + rulesClientParams.isSystemAction = (id: string) => id === 'system_action-id'; }); test('updates given parameters', async () => { @@ -234,6 +242,7 @@ describe('update()', () => { isSystemAction: false, }, ]); + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: '1', type: 'alert', @@ -251,6 +260,7 @@ describe('update()', () => { params: { foo: true, }, + uuid: '123', }, { group: 'default', @@ -259,6 +269,7 @@ describe('update()', () => { params: { foo: true, }, + uuid: '123', }, { group: 'default', @@ -267,6 +278,7 @@ describe('update()', () => { params: { foo: true, }, + uuid: '123', }, ], notifyWhen: 'onActiveAlert', @@ -293,6 +305,7 @@ describe('update()', () => { }, ], }); + const result = await rulesClient.update({ id: '1', data: { @@ -313,6 +326,7 @@ describe('update()', () => { params: { foo: true, }, + type: RuleActionTypes.DEFAULT, }, { group: 'default', @@ -320,6 +334,7 @@ describe('update()', () => { params: { foo: true, }, + type: RuleActionTypes.DEFAULT, }, { group: 'default', @@ -327,10 +342,12 @@ describe('update()', () => { params: { foo: true, }, + type: RuleActionTypes.DEFAULT, }, ], }, }); + expect(result).toMatchInlineSnapshot(` Object { "actions": Array [ @@ -341,6 +358,8 @@ describe('update()', () => { "params": Object { "foo": true, }, + "type": "default", + "uuid": "123", }, Object { "actionTypeId": "test", @@ -349,6 +368,8 @@ describe('update()', () => { "params": Object { "foo": true, }, + "type": "default", + "uuid": "123", }, Object { "actionTypeId": "test2", @@ -357,6 +378,8 @@ describe('update()', () => { "params": Object { "foo": true, }, + "type": "default", + "uuid": "123", }, ], "createdAt": 2019-02-12T21:01:22.479Z, @@ -550,6 +573,7 @@ describe('update()', () => { params: { foo: true, }, + uuid: '123', }, { group: 'default', @@ -558,6 +582,7 @@ describe('update()', () => { params: { foo: true, }, + uuid: '123', }, { group: 'custom', @@ -566,6 +591,7 @@ describe('update()', () => { params: { foo: true, }, + uuid: '123', }, ], notifyWhen: 'onActiveAlert', @@ -605,6 +631,7 @@ describe('update()', () => { params: { foo: true, }, + type: RuleActionTypes.DEFAULT, }, { group: 'default', @@ -612,6 +639,7 @@ describe('update()', () => { params: { foo: true, }, + type: RuleActionTypes.DEFAULT, }, { group: 'custom', @@ -619,6 +647,7 @@ describe('update()', () => { params: { foo: true, }, + type: RuleActionTypes.DEFAULT, }, ], }, @@ -693,6 +722,8 @@ describe('update()', () => { "params": Object { "foo": true, }, + "type": "default", + "uuid": "123", }, Object { "actionTypeId": "test", @@ -701,6 +732,8 @@ describe('update()', () => { "params": Object { "foo": true, }, + "type": "default", + "uuid": "123", }, Object { "actionTypeId": "test", @@ -709,6 +742,8 @@ describe('update()', () => { "params": Object { "foo": true, }, + "type": "default", + "uuid": "123", }, ], "createdAt": 2019-02-12T21:01:22.479Z, @@ -733,7 +768,7 @@ describe('update()', () => { expect(actionsClient.isPreconfigured).toHaveBeenCalledTimes(3); }); - test('should update a rule with some system actions', async () => { + test('should update a rule with default and system actions', async () => { actionsClient.getBulk.mockReset(); actionsClient.getBulk.mockResolvedValue([ { @@ -772,7 +807,7 @@ describe('update()', () => { }, { id: 'system_action-id', - actionTypeId: 'test', + actionTypeId: 'test-system', config: {}, isMissingSecrets: false, name: 'system action connector', @@ -781,10 +816,7 @@ describe('update()', () => { isSystemAction: true, }, ]); - actionsClient.isSystemAction.mockReset(); - actionsClient.isSystemAction.mockReturnValueOnce(false); - actionsClient.isSystemAction.mockReturnValueOnce(true); - actionsClient.isSystemAction.mockReturnValueOnce(true); + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: '1', type: 'alert', @@ -802,18 +834,14 @@ describe('update()', () => { params: { foo: true, }, + uuid: '123', }, { - group: 'default', - actionRef: 'system_action:system_action-id', - actionTypeId: 'test', - params: {}, - }, - { - group: 'custom', + uuid: 'system-action-uuid', actionRef: 'system_action:system_action-id', - actionTypeId: 'test', - params: {}, + actionTypeId: 'test-system', + params: { myNewParam: 'test' }, + type: RuleActionTypes.SYSTEM, }, ], notifyWhen: 'onActiveAlert', @@ -835,6 +863,7 @@ describe('update()', () => { }, ], }); + const result = await rulesClient.update({ id: '1', data: { @@ -853,16 +882,15 @@ describe('update()', () => { params: { foo: true, }, + type: RuleActionTypes.DEFAULT, }, { - group: 'default', - id: 'system_action-id', - params: {}, - }, - { - group: 'custom', id: 'system_action-id', - params: {}, + params: { + myNewParam: 'test', + }, + type: RuleActionTypes.SYSTEM, + uuid: 'system-action-uuid', }, ], }, @@ -883,18 +911,10 @@ describe('update()', () => { uuid: '106', }, { - group: 'default', - actionRef: 'system_action:system_action-id', - actionTypeId: 'test', - params: {}, - uuid: '107', - }, - { - group: 'custom', actionRef: 'system_action:system_action-id', - actionTypeId: 'test', - params: {}, - uuid: '108', + actionTypeId: 'test-system', + params: { myNewParam: 'test' }, + uuid: 'system-action-uuid', }, ], alertTypeId: 'myType', @@ -933,18 +953,17 @@ describe('update()', () => { "params": Object { "foo": true, }, + "type": "default", + "uuid": "123", }, Object { - "actionTypeId": "test", - "group": "default", - "id": "system_action-id", - "params": Object {}, - }, - Object { - "actionTypeId": "test", - "group": "custom", + "actionTypeId": "test-system", "id": "system_action-id", - "params": Object {}, + "params": Object { + "myNewParam": "test", + }, + "type": "system", + "uuid": "system-action-uuid", }, ], "createdAt": 2019-02-12T21:01:22.479Z, @@ -969,6 +988,630 @@ describe('update()', () => { expect(actionsClient.isSystemAction).toHaveBeenCalledTimes(3); }); + test('should construct the refs correctly and not persist the type of the action', async () => { + actionsClient.getBulk.mockReset(); + actionsClient.getBulk.mockResolvedValue([ + { + id: '1', + actionTypeId: 'test', + config: {}, + isMissingSecrets: false, + name: 'email connector', + isPreconfigured: false, + isDeprecated: false, + isSystemAction: false, + }, + { + id: 'system_action-id', + actionTypeId: 'test-system', + config: {}, + isMissingSecrets: false, + name: 'system action connector', + isPreconfigured: false, + isDeprecated: false, + isSystemAction: true, + }, + ]); + + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + enabled: true, + schedule: { interval: '1m' }, + params: { + bar: true, + }, + actions: [ + { + group: 'default', + actionRef: 'action_0', + actionTypeId: 'test', + params: { + foo: true, + }, + uuid: '123', + }, + { + uuid: 'system-action-uuid', + actionRef: 'system_action:system_action-id', + actionTypeId: 'test-system', + params: { myNewParam: 'test' }, + type: RuleActionTypes.SYSTEM, + }, + ], + notifyWhen: 'onActiveAlert', + revision: 1, + scheduledTaskId: 'task-123', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + { + name: 'param:soRef_0', + type: 'someSavedObjectType', + id: '9', + }, + ], + }); + + await rulesClient.update({ + id: '1', + data: { + schedule: { interval: '1m' }, + name: 'abc', + tags: ['foo'], + params: { + bar: true, + }, + throttle: null, + notifyWhen: 'onActiveAlert', + actions: [ + { + group: 'default', + id: '1', + params: { + foo: true, + }, + type: RuleActionTypes.DEFAULT, + }, + { + id: 'system_action-id', + params: { + myNewParam: 'test', + }, + type: RuleActionTypes.SYSTEM, + uuid: 'system-action-uuid', + }, + ], + }, + }); + + const rule = unsecuredSavedObjectsClient.create.mock.calls[0][1] as RuleDomain; + + expect(rule.actions).toEqual([ + { + group: 'default', + actionRef: 'action_0', + actionTypeId: 'test', + params: { + foo: true, + }, + uuid: '107', + }, + { + actionRef: 'system_action:system_action-id', + actionTypeId: 'test-system', + params: { myNewParam: 'test' }, + uuid: 'system-action-uuid', + }, + ]); + }); + + test('should add the actions type to the response correctly', async () => { + actionsClient.getBulk.mockReset(); + actionsClient.getBulk.mockResolvedValue([ + { + id: '1', + actionTypeId: 'test', + config: { + from: 'me@me.com', + hasAuth: false, + host: 'hello', + port: 22, + secure: null, + service: null, + }, + isMissingSecrets: false, + name: 'email connector', + isPreconfigured: false, + isDeprecated: false, + isSystemAction: false, + }, + { + id: 'system_action-id', + actionTypeId: 'test-system', + config: {}, + isMissingSecrets: false, + name: 'system action connector', + isPreconfigured: false, + isDeprecated: false, + isSystemAction: true, + }, + ]); + + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + enabled: true, + schedule: { interval: '1m' }, + params: { + bar: true, + }, + actions: [ + { + group: 'default', + actionRef: 'action_0', + actionTypeId: 'test', + params: { + foo: true, + }, + uuid: '123', + }, + { + uuid: 'system-action-uuid', + actionRef: 'system_action:system_action-id', + actionTypeId: 'test-system', + params: { myNewParam: 'test' }, + type: RuleActionTypes.SYSTEM, + }, + ], + notifyWhen: 'onActiveAlert', + revision: 1, + scheduledTaskId: 'task-123', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + { + name: 'param:soRef_0', + type: 'someSavedObjectType', + id: '9', + }, + ], + }); + + const result = await rulesClient.update({ + id: '1', + data: { + schedule: { interval: '1m' }, + name: 'abc', + tags: ['foo'], + params: { + bar: true, + }, + throttle: null, + notifyWhen: 'onActiveAlert', + actions: [ + { + group: 'default', + id: '1', + params: { + foo: true, + }, + type: RuleActionTypes.DEFAULT, + }, + { + id: 'system_action-id', + params: { + myNewParam: 'test', + }, + type: RuleActionTypes.SYSTEM, + uuid: 'system-action-uuid', + }, + ], + }, + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "actions": Array [ + Object { + "actionTypeId": "test", + "group": "default", + "id": "1", + "params": Object { + "foo": true, + }, + "type": "default", + "uuid": "123", + }, + Object { + "actionTypeId": "test-system", + "id": "system_action-id", + "params": Object { + "myNewParam": "test", + }, + "type": "system", + "uuid": "system-action-uuid", + }, + ], + "createdAt": 2019-02-12T21:01:22.479Z, + "enabled": true, + "id": "1", + "notifyWhen": "onActiveAlert", + "params": Object { + "bar": true, + }, + "revision": 1, + "schedule": Object { + "interval": "1m", + }, + "scheduledTaskId": "task-123", + "updatedAt": 2019-02-12T21:01:22.479Z, + } + `); + expect(encryptedSavedObjects.getDecryptedAsInternalUser).toHaveBeenCalledWith('alert', '1', { + namespace: 'default', + }); + expect(unsecuredSavedObjectsClient.get).not.toHaveBeenCalled(); + expect(actionsClient.isSystemAction).toHaveBeenCalledTimes(3); + }); + + test('should throw an error if the system action does not exist', async () => { + actionsClient.isSystemAction.mockReset(); + // Needed for validateSystemActions + actionsClient.isSystemAction.mockReturnValueOnce(false); + + actionsClient.getBulk.mockResolvedValue([ + { + id: 'system_action-id', + actionTypeId: 'test-system', + config: {}, + isMissingSecrets: false, + name: 'system action connector', + isPreconfigured: false, + isDeprecated: false, + isSystemAction: true, + }, + ]); + + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + enabled: true, + schedule: { interval: '1m' }, + params: { + bar: true, + }, + actions: [ + { + uuid: 'system-action-uuid', + actionRef: 'system_action:system_action-id', + actionTypeId: 'test-system', + params: { myNewParam: 'test' }, + type: RuleActionTypes.SYSTEM, + }, + ], + notifyWhen: 'onActiveAlert', + revision: 1, + scheduledTaskId: 'task-123', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + references: [ + { + name: 'param:soRef_0', + type: 'someSavedObjectType', + id: '9', + }, + ], + }); + + await expect(() => + rulesClient.update({ + id: '1', + data: { + schedule: { interval: '1m' }, + name: 'abc', + tags: ['foo'], + params: { + bar: true, + }, + throttle: null, + notifyWhen: 'onActiveAlert', + actions: [ + { + id: 'system_action-id', + params: { + myNewParam: 'test', + }, + type: RuleActionTypes.SYSTEM, + uuid: 'system-action-uuid', + }, + ], + }, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Action system_action-id is not a system action"` + ); + + expect(actionsClient.getBulk).toBeCalledWith({ + ids: ['system_action-id'], + throwIfSystemAction: false, + }); + }); + + test('should throw an error if the system action contains the frequency', async () => { + const action = { + id: 'system_action-id', + uuid: '123', + params: {}, + actionTypeId: '.test', + frequency: { + summary: false, + notifyWhen: 'onActionGroupChange', + throttle: null, + }, + type: RuleActionTypes.SYSTEM, + }; + + actionsClient.isSystemAction.mockReset(); + // Needed for validateSystemActions + actionsClient.isSystemAction.mockReturnValueOnce(true); + + actionsClient.getBulk.mockResolvedValue([ + { + id: 'system_action-id', + actionTypeId: 'test-system', + config: {}, + isMissingSecrets: false, + name: 'system action connector', + isPreconfigured: false, + isDeprecated: false, + isSystemAction: true, + }, + ]); + + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + enabled: true, + schedule: { interval: '1m' }, + params: { + bar: true, + }, + actions: [ + { + uuid: 'system-action-uuid', + actionRef: 'system_action:system_action-id', + actionTypeId: 'test-system', + params: { myNewParam: 'test' }, + type: RuleActionTypes.SYSTEM, + }, + ], + notifyWhen: 'onActiveAlert', + revision: 1, + scheduledTaskId: 'task-123', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + references: [ + { + name: 'param:soRef_0', + type: 'someSavedObjectType', + id: '9', + }, + ], + }); + + await expect(() => + rulesClient.update({ + id: '1', + data: { + schedule: { interval: '1m' }, + name: 'abc', + tags: ['foo'], + params: { + bar: true, + }, + throttle: null, + notifyWhen: 'onActiveAlert', + actions: [action], + }, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Error validating update actions: frequency or alertsFilter are not allowed for system actions"` + ); + }); + + test('should throw an error if the system action contains the alertsFilter', async () => { + const action = { + id: 'system_action-id', + uuid: '123', + params: {}, + actionTypeId: '.test', + alertsFilter: { + query: { kql: 'test:1', filters: [] }, + }, + type: RuleActionTypes.SYSTEM, + }; + + actionsClient.isSystemAction.mockReset(); + // Needed for validateSystemActions + actionsClient.isSystemAction.mockReturnValueOnce(true); + + actionsClient.getBulk.mockResolvedValue([ + { + id: 'system_action-id', + actionTypeId: 'test-system', + config: {}, + isMissingSecrets: false, + name: 'system action connector', + isPreconfigured: false, + isDeprecated: false, + isSystemAction: true, + }, + ]); + + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + enabled: true, + schedule: { interval: '1m' }, + params: { + bar: true, + }, + actions: [ + { + uuid: 'system-action-uuid', + actionRef: 'system_action:system_action-id', + actionTypeId: 'test-system', + params: { myNewParam: 'test' }, + type: RuleActionTypes.SYSTEM, + }, + ], + notifyWhen: 'onActiveAlert', + revision: 1, + scheduledTaskId: 'task-123', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + references: [ + { + name: 'param:soRef_0', + type: 'someSavedObjectType', + id: '9', + }, + ], + }); + + await expect(() => + rulesClient.update({ + id: '1', + data: { + schedule: { interval: '1m' }, + name: 'abc', + tags: ['foo'], + params: { + bar: true, + }, + throttle: null, + notifyWhen: 'onActiveAlert', + actions: [action], + }, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Error validating update actions: frequency or alertsFilter are not allowed for system actions"` + ); + }); + + test('should throw an error if the default action does not contain the group', async () => { + const action = { + id: 'action-id-1', + params: {}, + actionTypeId: '.test', + type: RuleActionTypes.DEFAULT, + }; + + actionsClient.isSystemAction.mockReset(); + // Needed for validateSystemActions + actionsClient.isSystemAction.mockReturnValueOnce(false); + actionsClient.getBulk.mockResolvedValue([ + { + id: '1', + actionTypeId: 'test', + config: { + from: 'me@me.com', + hasAuth: false, + host: 'hello', + port: 22, + secure: null, + service: null, + }, + isMissingSecrets: false, + name: 'email connector', + isPreconfigured: false, + isDeprecated: false, + isSystemAction: false, + }, + ]); + + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + enabled: true, + schedule: { interval: '1m' }, + params: { + bar: true, + }, + actions: [ + { + group: 'default', + actionRef: 'action_0', + actionTypeId: 'test', + params: { + foo: true, + }, + uuid: '123', + }, + ], + notifyWhen: 'onActiveAlert', + revision: 1, + scheduledTaskId: 'task-123', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + { + name: 'param:soRef_0', + type: 'someSavedObjectType', + id: '9', + }, + ], + }); + + await expect(() => + rulesClient.update({ + id: '1', + data: { + schedule: { interval: '1m' }, + name: 'abc', + tags: ['foo'], + params: { + bar: true, + }, + throttle: null, + notifyWhen: 'onActiveAlert', + // @ts-expect-error: group is undefined + actions: [action], + }, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Error validating update actions: group is required for alert actions"` + ); + }); + test('should call useSavedObjectReferences.extractReferences and useSavedObjectReferences.injectReferences if defined for rule type', async () => { const ruleParams = { bar: true, @@ -1029,6 +1672,7 @@ describe('update()', () => { params: { foo: true, }, + uuid: '123', }, ], notifyWhen: 'onActiveAlert', @@ -1066,6 +1710,7 @@ describe('update()', () => { params: { foo: true, }, + type: RuleActionTypes.DEFAULT, }, ], }, @@ -1130,6 +1775,8 @@ describe('update()', () => { "params": Object { "foo": true, }, + "type": "default", + "uuid": "123", }, ], "createdAt": 2019-02-12T21:01:22.479Z, @@ -1175,6 +1822,7 @@ describe('update()', () => { params: { foo: true, }, + uuid: '123', }, ], apiKey: Buffer.from('123:abc').toString('base64'), @@ -1208,6 +1856,7 @@ describe('update()', () => { params: { foo: true, }, + type: RuleActionTypes.DEFAULT, }, ], }, @@ -1222,6 +1871,8 @@ describe('update()', () => { "params": Object { "foo": true, }, + "type": "default", + "uuid": "123", }, ], "apiKey": "MTIzOmFiYw==", @@ -1335,6 +1986,7 @@ describe('update()', () => { params: { foo: true, }, + uuid: '123', }, ], revision: 1, @@ -1368,6 +2020,7 @@ describe('update()', () => { params: { foo: true, }, + type: RuleActionTypes.DEFAULT, }, ], }, @@ -1383,6 +2036,8 @@ describe('update()', () => { "params": Object { "foo": true, }, + "type": "default", + "uuid": "123", }, ], "apiKey": null, @@ -1484,6 +2139,7 @@ describe('update()', () => { params: { foo: true, }, + type: RuleActionTypes.DEFAULT, }, { group: 'default', @@ -1491,6 +2147,7 @@ describe('update()', () => { params: { foo: true, }, + type: RuleActionTypes.DEFAULT, }, { group: 'default', @@ -1498,6 +2155,7 @@ describe('update()', () => { params: { foo: true, }, + type: RuleActionTypes.DEFAULT, }, ], }, @@ -1545,6 +2203,7 @@ describe('update()', () => { params: { foo: true, }, + type: RuleActionTypes.DEFAULT, }, ], }, @@ -1579,6 +2238,7 @@ describe('update()', () => { notifyWhen: 'onActionGroupChange', throttle: null, }, + uuid: '123', }, ], scheduledTaskId: 'task-123', @@ -1627,6 +2287,7 @@ describe('update()', () => { notifyWhen: 'onActionGroupChange', throttle: null, }, + uuid: '123', }, ], scheduledTaskId: 'task-123', @@ -1661,6 +2322,7 @@ describe('update()', () => { notifyWhen: 'onActionGroupChange', throttle: null, }, + type: RuleActionTypes.DEFAULT, }, ], }, @@ -1735,6 +2397,7 @@ describe('update()', () => { notifyWhen: 'onActionGroupChange', throttle: null, }, + uuid: '123', }, { group: 'default', @@ -1748,6 +2411,7 @@ describe('update()', () => { notifyWhen: 'onActionGroupChange', throttle: null, }, + uuid: '123', }, { group: 'default', @@ -1761,6 +2425,7 @@ describe('update()', () => { notifyWhen: 'onActionGroupChange', throttle: null, }, + uuid: '123', }, ], scheduledTaskId: 'task-123', @@ -1806,6 +2471,7 @@ describe('update()', () => { notifyWhen: 'onThrottleInterval', throttle: '5m', }, + type: RuleActionTypes.DEFAULT, }, { group: 'default', @@ -1818,6 +2484,7 @@ describe('update()', () => { notifyWhen: 'onThrottleInterval', throttle: '5m', }, + type: RuleActionTypes.DEFAULT, }, { group: 'default', @@ -1830,6 +2497,7 @@ describe('update()', () => { notifyWhen: 'onThrottleInterval', throttle: '5m', }, + type: RuleActionTypes.DEFAULT, }, ], }, @@ -1868,6 +2536,7 @@ describe('update()', () => { notifyWhen: 'onActionGroupChange', throttle: null, }, + type: RuleActionTypes.DEFAULT, }, ], }, @@ -1956,6 +2625,7 @@ describe('update()', () => { notifyWhen: 'onActionGroupChange', throttle: null, }, + uuid: '123', }, ], scheduledTaskId: taskId, @@ -1999,6 +2669,7 @@ describe('update()', () => { notifyWhen: 'onActionGroupChange', throttle: null, }, + type: RuleActionTypes.DEFAULT, }, ], }, @@ -2034,6 +2705,7 @@ describe('update()', () => { notifyWhen: 'onActionGroupChange', throttle: null, }, + type: RuleActionTypes.DEFAULT, }, ], }, @@ -2071,6 +2743,7 @@ describe('update()', () => { notifyWhen: 'onActionGroupChange', throttle: null, }, + type: RuleActionTypes.DEFAULT, }, { group: 'group2', @@ -2083,6 +2756,7 @@ describe('update()', () => { notifyWhen: 'onActionGroupChange', throttle: null, }, + type: RuleActionTypes.DEFAULT, }, ], }, @@ -2117,6 +2791,7 @@ describe('update()', () => { notifyWhen: 'onActionGroupChange', throttle: null, }, + type: RuleActionTypes.DEFAULT, }, { group: 'default', @@ -2124,6 +2799,7 @@ describe('update()', () => { params: { foo: true, }, + type: RuleActionTypes.DEFAULT, }, ], }, @@ -2160,6 +2836,7 @@ describe('update()', () => { params: { foo: true, }, + type: RuleActionTypes.DEFAULT, }, ], }, @@ -2201,6 +2878,7 @@ describe('update()', () => { notifyWhen: 'onActionGroupChange', throttle: null, }, + type: RuleActionTypes.DEFAULT, }, { group: 'default', @@ -2208,6 +2886,7 @@ describe('update()', () => { params: { foo: true, }, + type: RuleActionTypes.DEFAULT, }, ], }, @@ -2249,6 +2928,7 @@ describe('update()', () => { notifyWhen: 'onActionGroupChange', throttle: null, }, + type: RuleActionTypes.DEFAULT, }, ], }, @@ -2321,6 +3001,7 @@ describe('update()', () => { params: { foo: true, }, + type: RuleActionTypes.DEFAULT, }, { group: 'default', @@ -2328,6 +3009,7 @@ describe('update()', () => { params: { foo: true, }, + type: RuleActionTypes.DEFAULT, }, { group: 'default', @@ -2335,6 +3017,7 @@ describe('update()', () => { params: { foo: true, }, + type: RuleActionTypes.DEFAULT, }, ], }, @@ -2381,6 +3064,7 @@ describe('update()', () => { params: { foo: true, }, + uuid: '123', }, ], notifyWhen: 'onActiveAlert', @@ -2420,6 +3104,7 @@ describe('update()', () => { params: { foo: true, }, + type: RuleActionTypes.DEFAULT, }, ], }, @@ -2438,7 +3123,7 @@ describe('update()', () => { params: { foo: true, }, - uuid: '147', + uuid: '146', }, ], alertTypeId: 'myType', @@ -2477,6 +3162,8 @@ describe('update()', () => { "params": Object { "foo": true, }, + "type": "default", + "uuid": "123", }, ], "createdAt": 2019-02-12T21:01:22.479Z, @@ -2556,6 +3243,7 @@ describe('update()', () => { params: { foo: true, }, + uuid: '123', }, { group: 'default', @@ -2564,6 +3252,7 @@ describe('update()', () => { params: { foo: true, }, + uuid: '123', }, { group: 'default', @@ -2572,6 +3261,7 @@ describe('update()', () => { params: { foo: true, }, + uuid: '123', }, ], notifyWhen: 'onActiveAlert', @@ -2615,6 +3305,7 @@ describe('update()', () => { params: { foo: true, }, + type: RuleActionTypes.DEFAULT, }, { group: 'default', @@ -2622,6 +3313,7 @@ describe('update()', () => { params: { foo: true, }, + type: RuleActionTypes.DEFAULT, }, { group: 'default', @@ -2629,6 +3321,7 @@ describe('update()', () => { params: { foo: true, }, + type: RuleActionTypes.DEFAULT, }, ], }, @@ -2663,6 +3356,7 @@ describe('update()', () => { params: { foo: true, }, + type: RuleActionTypes.DEFAULT, }, { group: 'default', @@ -2670,6 +3364,7 @@ describe('update()', () => { params: { foo: true, }, + type: RuleActionTypes.DEFAULT, }, { group: 'default', @@ -2677,6 +3372,7 @@ describe('update()', () => { params: { foo: true, }, + type: RuleActionTypes.DEFAULT, }, ], }, @@ -2955,6 +3651,7 @@ describe('update()', () => { summary: false, }, uuid: '123-456', + type: RuleActionTypes.DEFAULT, }, { group: 'default', @@ -2967,6 +3664,7 @@ describe('update()', () => { throttle: null, summary: false, }, + type: RuleActionTypes.DEFAULT, }, ], }, @@ -2989,7 +3687,7 @@ describe('update()', () => { frequency: { notifyWhen: 'onActiveAlert', summary: false, throttle: null }, group: 'default', params: { foo: true }, - uuid: '154', + uuid: '153', }, ], alertTypeId: 'myType', @@ -3103,6 +3801,7 @@ describe('update()', () => { params: { foo: true, }, + uuid: '123', }, ], apiKey: Buffer.from('123:abc').toString('base64'), @@ -3145,6 +3844,7 @@ describe('update()', () => { params: { foo: true, }, + type: RuleActionTypes.DEFAULT, }, ], }, @@ -3159,6 +3859,8 @@ describe('update()', () => { "params": Object { "foo": true, }, + "type": "default", + "uuid": "123", }, ], "apiKey": "MTIzOmFiYw==", @@ -3193,7 +3895,7 @@ describe('update()', () => { "params": Object { "foo": true, }, - "uuid": "155", + "uuid": "154", }, ], "alertTypeId": "myType", @@ -3268,6 +3970,7 @@ describe('update()', () => { notifyWhen: 'onActionGroupChange', throttle: null, }, + type: RuleActionTypes.DEFAULT, }, ], }, diff --git a/x-pack/plugins/alerting/server/rules_client/tests/update_api_key.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/update_api_key.test.ts index dfaf52a8f79c6..f177d90e540a2 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/update_api_key.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/update_api_key.test.ts @@ -60,6 +60,7 @@ const rulesClientParams: jest.Mocked = { isAuthenticationTypeAPIKey: jest.fn(), getAuthenticationAPIKey: jest.fn(), connectorAdapterRegistry: new ConnectorAdapterRegistry(), + isSystemAction: jest.fn(), }; beforeEach(() => { diff --git a/x-pack/plugins/alerting/server/rules_client/types.ts b/x-pack/plugins/alerting/server/rules_client/types.ts index fc9cb76792ee8..6be77417ddce8 100644 --- a/x-pack/plugins/alerting/server/rules_client/types.ts +++ b/x-pack/plugins/alerting/server/rules_client/types.ts @@ -21,6 +21,7 @@ import { EncryptedSavedObjectsClient } from '@kbn/encrypted-saved-objects-plugin import { TaskManagerStartContract } from '@kbn/task-manager-plugin/server'; import { IEventLogClient, IEventLogger } from '@kbn/event-log-plugin/server'; import { AuditLogger } from '@kbn/security-plugin/server'; +import { DistributiveOmit } from '@elastic/eui'; import { RegistryRuleType } from '../rule_type_registry'; import { RuleTypeRegistry, @@ -29,6 +30,8 @@ import { SanitizedRule, RuleSnoozeSchedule, RawRuleAlertsFilter, + RuleSystemAction, + RuleDefaultAction, } from '../types'; import { AlertingAuthorization } from '../authorization'; import { AlertingRulesConfig } from '../config'; @@ -76,18 +79,29 @@ export interface RulesClientContext { readonly isAuthenticationTypeAPIKey: () => boolean; readonly getAuthenticationAPIKey: (name: string) => CreateAPIKeyResult; readonly connectorAdapterRegistry: ConnectorAdapterRegistry; + readonly isSystemAction: (actionId: string) => boolean; } -export type NormalizedAlertAction = Omit; +export type NormalizedAlertAction = DistributiveOmit; +export type NormalizedSystemAction = Omit; -export type NormalizedAlertActionWithGeneratedValues = Omit< - NormalizedAlertAction, - 'uuid' | 'alertsFilter' +export type NormalizedAlertDefaultActionWithGeneratedValues = Omit< + RuleDefaultAction, + 'uuid' | 'alertsFilter' | 'actionTypeId' > & { uuid: string; alertsFilter?: RawRuleAlertsFilter; }; +export type NormalizedAlertSystemActionWithGeneratedValues = Omit< + RuleSystemAction, + 'uuid' | 'actionTypeId' +> & { uuid: string }; + +export type NormalizedAlertActionWithGeneratedValues = + | NormalizedAlertDefaultActionWithGeneratedValues + | NormalizedAlertSystemActionWithGeneratedValues; + export interface RegistryAlertTypeWithAuth extends RegistryRuleType { authorizedConsumers: string[]; } diff --git a/x-pack/plugins/alerting/server/rules_client_conflict_retries.test.ts b/x-pack/plugins/alerting/server/rules_client_conflict_retries.test.ts index f93062db1e3cc..bfce9819c1e24 100644 --- a/x-pack/plugins/alerting/server/rules_client_conflict_retries.test.ts +++ b/x-pack/plugins/alerting/server/rules_client_conflict_retries.test.ts @@ -68,6 +68,7 @@ const rulesClientParams: jest.Mocked = { isAuthenticationTypeAPIKey: jest.fn(), getAuthenticationAPIKey: jest.fn(), connectorAdapterRegistry: new ConnectorAdapterRegistry(), + isSystemAction: jest.fn(), }; // this suite consists of two suites running tests against mutable RulesClient APIs: diff --git a/x-pack/plugins/alerting/server/rules_client_factory.test.ts b/x-pack/plugins/alerting/server/rules_client_factory.test.ts index 01ff329aead75..79cb83f13da9f 100644 --- a/x-pack/plugins/alerting/server/rules_client_factory.test.ts +++ b/x-pack/plugins/alerting/server/rules_client_factory.test.ts @@ -115,6 +115,7 @@ test('creates a rules client with proper constructor arguments when security is isAuthenticationTypeAPIKey: expect.any(Function), getAuthenticationAPIKey: expect.any(Function), connectorAdapterRegistry: expect.any(ConnectorAdapterRegistry), + isSystemAction: expect.any(Function), }); }); @@ -158,6 +159,7 @@ test('creates a rules client with proper constructor arguments', async () => { isAuthenticationTypeAPIKey: expect.any(Function), getAuthenticationAPIKey: expect.any(Function), connectorAdapterRegistry: expect.any(ConnectorAdapterRegistry), + isSystemAction: expect.any(Function), }); }); diff --git a/x-pack/plugins/alerting/server/rules_client_factory.ts b/x-pack/plugins/alerting/server/rules_client_factory.ts index e2bd6e7e3a9a2..4e982bae3fa90 100644 --- a/x-pack/plugins/alerting/server/rules_client_factory.ts +++ b/x-pack/plugins/alerting/server/rules_client_factory.ts @@ -175,6 +175,9 @@ export class RulesClientFactory { } return { apiKeysEnabled: false }; }, + isSystemAction(actionId: string) { + return actions.isSystemActionConnector(actionId); + }, }); } } diff --git a/x-pack/plugins/alerting/server/saved_objects/index.ts b/x-pack/plugins/alerting/server/saved_objects/index.ts index c16356193fd8d..a07bd07df666f 100644 --- a/x-pack/plugins/alerting/server/saved_objects/index.ts +++ b/x-pack/plugins/alerting/server/saved_objects/index.ts @@ -61,7 +61,6 @@ export type AlertAttributesExcludedFromAADType = | 'updatedAt' | 'executionStatus' | 'monitoring' - | 'snoozeEndTime' | 'snoozeSchedule' | 'isSnoozedUntil' | 'lastRun' diff --git a/x-pack/plugins/alerting/server/saved_objects/migrations/7.16/index.ts b/x-pack/plugins/alerting/server/saved_objects/migrations/7.16/index.ts index bf2870eb613bb..ab1b5788de781 100644 --- a/x-pack/plugins/alerting/server/saved_objects/migrations/7.16/index.ts +++ b/x-pack/plugins/alerting/server/saved_objects/migrations/7.16/index.ts @@ -5,11 +5,11 @@ * 2.0. */ -import { SavedObjectAttribute, SavedObjectReference } from '@kbn/core-saved-objects-server'; +import { SavedObjectReference } from '@kbn/core-saved-objects-server'; import { SavedObjectUnsanitizedDoc } from '@kbn/core-saved-objects-server'; import { EncryptedSavedObjectsPluginSetup } from '@kbn/encrypted-saved-objects-plugin/server'; import { isString } from 'lodash/fp'; -import { RawRule, RawRuleAction } from '../../../types'; +import { RawRuleAction, RawRule } from '../../../types'; import { extractRefsFromGeoContainmentAlert } from '../../geo_containment/migrations'; import { createEsoMigration, isSecuritySolutionLegacyNotification, pipeMigrations } from '../utils'; @@ -33,7 +33,7 @@ function getRemovePreconfiguredConnectorsFromReferencesFn( } function getCorrespondingAction( - actions: SavedObjectAttribute, + actions: RawRuleAction[], connectorRef: string ): RawRuleAction | null { if (!Array.isArray(actions)) { @@ -64,14 +64,18 @@ function removePreconfiguredConnectorsFromReferences( ); const updatedConnectorReferences: SavedObjectReference[] = []; - const updatedActions: RawRule['actions'] = []; + const updatedActions: RawRuleAction[] = []; // For each connector reference, check if connector is preconfigured // If yes, we need to remove from the references array and update // the corresponding action so it directly references the preconfigured connector id connectorReferences.forEach((connectorRef: SavedObjectReference) => { // Look for the corresponding entry in the actions array - const correspondingAction = getCorrespondingAction(actions, connectorRef.name); + const correspondingAction = getCorrespondingAction( + actions as RawRuleAction[], + connectorRef.name + ); + if (correspondingAction) { if (isPreconfigured(connectorRef.id)) { updatedActions.push({ diff --git a/x-pack/plugins/alerting/server/saved_objects/migrations/8.0/index.ts b/x-pack/plugins/alerting/server/saved_objects/migrations/8.0/index.ts index 5d8efd4585af7..bed9b255cd30e 100644 --- a/x-pack/plugins/alerting/server/saved_objects/migrations/8.0/index.ts +++ b/x-pack/plugins/alerting/server/saved_objects/migrations/8.0/index.ts @@ -8,7 +8,7 @@ import { SavedObjectUnsanitizedDoc } from '@kbn/core-saved-objects-server'; import { EncryptedSavedObjectsPluginSetup } from '@kbn/encrypted-saved-objects-plugin/server'; import { isRuleType, ruleTypeMappings } from '@kbn/securitysolution-rules'; -import { RawRule } from '../../../types'; +import { RawRuleAction, RawRule } from '../../../types'; import { FILEBEAT_7X_INDICATOR_PATH } from '../constants'; import { createEsoMigration, @@ -91,7 +91,7 @@ function fixInventoryThresholdGroupId( const updatedActions = actions ? actions.map((action) => { // Wrong spelling - if (action.group === 'metrics.invenotry_threshold.fired') { + if ((action as RawRuleAction).group === 'metrics.invenotry_threshold.fired') { return { ...action, group: 'metrics.inventory_threshold.fired', diff --git a/x-pack/plugins/alerting/server/saved_objects/migrations/8.3/index.ts b/x-pack/plugins/alerting/server/saved_objects/migrations/8.3/index.ts index 833971a71dbbe..7c3658e8c8d60 100644 --- a/x-pack/plugins/alerting/server/saved_objects/migrations/8.3/index.ts +++ b/x-pack/plugins/alerting/server/saved_objects/migrations/8.3/index.ts @@ -61,12 +61,14 @@ function removeInternalTags( }; } +type RawRule83 = RawRule & { snoozeEndTime: string }; + function convertSnoozes( doc: SavedObjectUnsanitizedDoc ): SavedObjectUnsanitizedDoc { const { attributes: { snoozeEndTime }, - } = doc; + } = doc as SavedObjectUnsanitizedDoc; return { ...doc, @@ -75,7 +77,7 @@ function convertSnoozes( snoozeSchedule: snoozeEndTime ? [ { - duration: Date.parse(snoozeEndTime as string) - Date.now(), + duration: Date.parse(snoozeEndTime) - Date.now(), rRule: { dtstart: new Date().toISOString(), tzid: moment.tz.guess(), diff --git a/x-pack/plugins/alerting/server/saved_objects/migrations/8.8/index.ts b/x-pack/plugins/alerting/server/saved_objects/migrations/8.8/index.ts index 24629a3a146bd..7ac2495d637cf 100644 --- a/x-pack/plugins/alerting/server/saved_objects/migrations/8.8/index.ts +++ b/x-pack/plugins/alerting/server/saved_objects/migrations/8.8/index.ts @@ -9,7 +9,7 @@ import { SavedObjectUnsanitizedDoc } from '@kbn/core-saved-objects-server'; import { EncryptedSavedObjectsPluginSetup } from '@kbn/encrypted-saved-objects-plugin/server'; import { v4 as uuidv4 } from 'uuid'; import { createEsoMigration, isDetectionEngineAADRuleType, pipeMigrations } from '../utils'; -import { RawRule } from '../../../types'; +import { RawRuleAction, RawRule } from '../../../types'; import { transformToAlertThrottle } from '../../../rules_client/lib/siem_legacy_actions/transform_to_alert_throttle'; import { transformToNotifyWhen } from '../../../rules_client/lib/siem_legacy_actions/transform_to_notify_when'; @@ -57,7 +57,7 @@ function addSecuritySolutionActionsFrequency( attributes: { ...doc.attributes, actions: actions - ? actions.map((action) => ({ + ? (actions as RawRuleAction[]).map((action) => ({ ...action, // Till now SIEM worked without action level frequencies. Instead rule level `throttle` and `notifyWhen` used frequency: action.frequency ?? { diff --git a/x-pack/plugins/alerting/server/saved_objects/migrations/index.ts b/x-pack/plugins/alerting/server/saved_objects/migrations/index.ts index b94fb907d8275..e3ac9fda4fe08 100644 --- a/x-pack/plugins/alerting/server/saved_objects/migrations/index.ts +++ b/x-pack/plugins/alerting/server/saved_objects/migrations/index.ts @@ -16,6 +16,7 @@ import { EncryptedSavedObjectsPluginSetup } from '@kbn/encrypted-saved-objects-p import { MigrateFunctionsObject, MigrateFunction } from '@kbn/kibana-utils-plugin/common'; import { mergeSavedObjectMigrationMaps } from '@kbn/core/server'; import { isSerializedSearchSource, SerializedSearchSourceFields } from '@kbn/data-plugin/common'; +import { Serializable } from '@kbn/utility-types'; import { RawRule } from '../../types'; import { getMigrations7100 } from './7.10'; import { getMigrations7110, getMigrations7112 } from './7.11'; @@ -107,27 +108,33 @@ function executeMigrationWithErrorHandling( }; } +interface SerializableRawRule { + attributes: { + params: { searchConfiguration: SerializedSearchSourceFields }; + [key: string]: Serializable; + }; + [key: string]: Serializable; +} + function mapSearchSourceMigrationFunc( migrateSerializedSearchSourceFields: MigrateFunction -): MigrateFunction { +): MigrateFunction { return (doc) => { - const _doc = doc as { attributes: RawRule }; - - const serializedSearchSource = _doc.attributes.params.searchConfiguration; + const serializedSearchSource = doc.attributes.params.searchConfiguration; if (isSerializedSearchSource(serializedSearchSource)) { return { - ..._doc, + ...doc, attributes: { - ..._doc.attributes, + ...doc.attributes, params: { - ..._doc.attributes.params, + ...doc.attributes.params, searchConfiguration: migrateSerializedSearchSourceFields(serializedSearchSource), }, }, }; } - return _doc; + return doc; }; } diff --git a/x-pack/plugins/alerting/server/task_runner/execution_handler.ts b/x-pack/plugins/alerting/server/task_runner/execution_handler.ts index d12b3428e62eb..fde39f906485a 100644 --- a/x-pack/plugins/alerting/server/task_runner/execution_handler.ts +++ b/x-pack/plugins/alerting/server/task_runner/execution_handler.ts @@ -39,7 +39,8 @@ import { RuleTypeState, SanitizedRule, RuleAlertData, - RuleActionTypes, + RuleDefaultAction, + RuleSystemAction, RuleNotifyWhen, } from '../../common'; import { @@ -52,6 +53,7 @@ import { isSummaryActionThrottled, } from './rule_action_helper'; import { ConnectorAdapter } from '../connector_adapters/types'; +import { isSystemAction } from '../../common/system_actions/is_system_action'; enum Reasons { MUTED = 'muted', @@ -64,7 +66,7 @@ export interface RunResult { } interface RunSummarizedActionArgs { - action: RuleAction; + action: RuleDefaultAction; summarizedAlerts: CombinedSummarizedAlerts; spaceId: string; } @@ -75,14 +77,14 @@ interface RunActionArgs< ActionGroupIds extends string, RecoveryActionGroupId extends string > { - action: RuleAction; + action: RuleDefaultAction; alert: Alert; ruleId: string; spaceId: string; } interface RunSystemActionArgs { - action: RuleAction; + action: RuleSystemAction; connectorAdapter: ConnectorAdapter; summarizedAlerts: CombinedSummarizedAlerts; rule: SanitizedRule; @@ -615,7 +617,7 @@ export class ExecutionHandler< action, }: { alert: Alert; - action: RuleAction; + action: RuleDefaultAction; }) { const alertId = alert.getId(); const { rule, ruleLabel, logger } = this; @@ -943,15 +945,3 @@ export class ExecutionHandler< return bulkActions; } } - -/** - * TODO: Substitute with a function which takes into - * account system actions. - * - * Because RuleAction has the type set as RuleActionTypes.DEFAULT - * TS produce an error as the check below will always return false. - * We need the check to be able to test. - */ - -// @ts-expect-error -const isSystemAction = (action: RuleAction) => action.type === RuleActionTypes.SYSTEM; diff --git a/x-pack/plugins/alerting/server/task_runner/fixtures.ts b/x-pack/plugins/alerting/server/task_runner/fixtures.ts index 64c798b868db1..4157f4a52b108 100644 --- a/x-pack/plugins/alerting/server/task_runner/fixtures.ts +++ b/x-pack/plugins/alerting/server/task_runner/fixtures.ts @@ -15,6 +15,8 @@ import { RuleLastRunOutcomeOrderMap, RuleLastRunOutcomes, SanitizedRule, + SanitizedRuleAction, + RuleActionTypes, } from '../../common'; import { getDefaultMonitoring } from '../lib/monitoring'; import { UntypedNormalizedRuleType } from '../rule_type_registry'; @@ -43,6 +45,7 @@ export const RULE_ACTIONS = [ foo: true, }, uuid: '111-111', + type: RuleActionTypes.DEFAULT, }, { actionTypeId: 'action', @@ -52,6 +55,7 @@ export const RULE_ACTIONS = [ isResolved: true, }, uuid: '222-222', + type: RuleActionTypes.DEFAULT, }, ]; @@ -190,6 +194,7 @@ export const mockedRuleTypeSavedObject: Rule = { foo: true, }, uuid: '111-111', + type: RuleActionTypes.DEFAULT, }, { group: RecoveredActionGroup.id, @@ -199,6 +204,7 @@ export const mockedRuleTypeSavedObject: Rule = { isResolved: true, }, uuid: '222-222', + type: RuleActionTypes.DEFAULT, }, ], executionStatus: { @@ -281,7 +287,8 @@ export const mockedRule: SanitizedRule return { ...action, id: action.uuid, - }; + type: RuleActionTypes.DEFAULT, + } as SanitizedRuleAction; }), isSnoozedUntil: undefined, }; diff --git a/x-pack/plugins/alerting/server/task_runner/rule_action_helper.test.ts b/x-pack/plugins/alerting/server/task_runner/rule_action_helper.test.ts index 84fb89de01cbf..ca2dceb497841 100644 --- a/x-pack/plugins/alerting/server/task_runner/rule_action_helper.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/rule_action_helper.test.ts @@ -6,7 +6,7 @@ */ import { Logger } from '@kbn/logging'; -import { RuleAction, RuleActionTypes } from '../types'; +import { RuleAction, RuleActionTypes, RuleSystemAction } from '../types'; import { generateActionHash, getSummaryActionsFromTaskState, @@ -26,6 +26,7 @@ const mockOldAction: RuleAction = { actionTypeId: 'slack', params: {}, uuid: '123-456', + type: RuleActionTypes.DEFAULT, }; const mockAction: RuleAction = { @@ -39,6 +40,7 @@ const mockAction: RuleAction = { throttle: null, }, uuid: '123-456', + type: RuleActionTypes.DEFAULT, }; const mockSummaryAction: RuleAction = { @@ -52,11 +54,11 @@ const mockSummaryAction: RuleAction = { throttle: '1d', }, uuid: '111-111', + type: RuleActionTypes.DEFAULT, }; -const mockSystemAction = { +const mockSystemAction: RuleSystemAction = { id: '1', - group: 'default', actionTypeId: '.test', params: {}, uuid: '123-456', @@ -71,8 +73,6 @@ describe('rule_action_helper', () => { }); test('should return false if the action is a system action', () => { - // TODO: Remove when system actions are introduced in types - // @ts-expect-error: cannot accept system actions at the moment const result = isSummaryAction(mockSystemAction); expect(result).toBe(false); }); @@ -105,8 +105,6 @@ describe('rule_action_helper', () => { }); test('should return false if the action is a system action', () => { - // TODO: Remove when system actions are introduced in types - // @ts-expect-error: cannot accept system actions at the moment const result = isActionOnInterval(mockSystemAction); expect(result).toBe(false); }); @@ -150,8 +148,6 @@ describe('rule_action_helper', () => { }); test('should return a hash for system actions action', () => { - // TODO: Remove when system actions are introduced in types - // @ts-expect-error: cannot accept system actions at the moment const result = generateActionHash(mockSystemAction); expect(result).toBe('system-action:.test:summary'); }); @@ -186,8 +182,6 @@ describe('rule_action_helper', () => { test('should filtered out system actions', () => { const result = getSummaryActionsFromTaskState({ - // TODO: Remove when system actions are introduced in types - // @ts-expect-error: cannot accept system actions at the moment actions: [mockSummaryAction, mockSystemAction], summaryActions: { '111-111': { date: new Date('01.01.2020').toISOString() }, @@ -236,8 +230,6 @@ describe('rule_action_helper', () => { }); test('should return false if the action is a system action', () => { - // TODO: Remove when system actions are introduced in types - // @ts-expect-error: cannot accept system actions at the moment const result = isSummaryActionThrottled({ action: mockSystemAction, logger }); expect(result).toBe(false); }); @@ -347,8 +339,6 @@ describe('rule_action_helper', () => { }); test('should return false if the action is a system action', () => { - // TODO: Remove when system actions are introduced in types - // @ts-expect-error: cannot accept system actions at the moment const result = isSummaryActionOnInterval(mockSystemAction); expect(result).toBe(false); }); @@ -381,8 +371,6 @@ describe('rule_action_helper', () => { }); test('returns undefined start and end action is a system action', () => { - // TODO: Remove when system actions are introduced in types - // @ts-expect-error: cannot accept system actions at the moment expect(getSummaryActionTimeBounds(mockSystemAction, { interval: '1m' }, null)).toEqual({ start: undefined, end: undefined, diff --git a/x-pack/plugins/alerting/server/task_runner/rule_action_helper.ts b/x-pack/plugins/alerting/server/task_runner/rule_action_helper.ts index 158d5bbcbcf37..94d3486ec96aa 100644 --- a/x-pack/plugins/alerting/server/task_runner/rule_action_helper.ts +++ b/x-pack/plugins/alerting/server/task_runner/rule_action_helper.ts @@ -10,10 +10,11 @@ import { IntervalSchedule, parseDuration, RuleAction, + RuleDefaultAction, RuleNotifyWhenTypeValues, ThrottledActions, - RuleActionTypes, } from '../../common'; +import { isSystemAction } from '../../common/system_actions/is_system_action'; export const isSummaryAction = (action?: RuleAction) => { if (action != null && isSystemAction(action)) { @@ -108,7 +109,7 @@ export const getSummaryActionsFromTaskState = ({ summaryActions?: ThrottledActions; }) => { const actionsWithoutSystemActions = actions.filter( - (action): action is RuleAction => !isSystemAction(action) + (action): action is RuleDefaultAction => !isSystemAction(action) ); return Object.entries(summaryActions).reduce((newObj, [key, val]) => { @@ -154,15 +155,3 @@ export const getSummaryActionTimeBounds = ( return { start: startDate.valueOf(), end: now.valueOf() }; }; - -/** - * TODO: Substitute with a function which takes into - * account system actions. - * - * Because RuleAction has the type set as RuleActionTypes.DEFAULT - * TS produce an error as the check below will always return false. - * We need the check to be able to test. - */ - -// @ts-expect-error -const isSystemAction = (action: RuleAction) => action.type === RuleActionTypes.SYSTEM; diff --git a/x-pack/plugins/alerting/server/task_runner/rule_loader.ts b/x-pack/plugins/alerting/server/task_runner/rule_loader.ts index f6bb71aef7453..0218d24652155 100644 --- a/x-pack/plugins/alerting/server/task_runner/rule_loader.ts +++ b/x-pack/plugins/alerting/server/task_runner/rule_loader.ts @@ -25,8 +25,12 @@ import { import { MONITORING_HISTORY_LIMIT, RuleTypeParams } from '../../common'; import { AlertingEventLogger } from '../lib/alerting_event_logger/alerting_event_logger'; -export interface RuleData extends LoadedIndirectParams { - indirectParams: RawRule; +interface SerializableRawRule extends RawRule { + [key: string]: unknown; +} + +export interface RuleData + extends LoadedIndirectParams { rule: SanitizedRule; version: string | undefined; fakeRequest: CoreKibanaRequest; @@ -121,6 +125,7 @@ export async function getRuleAttributes( const fakeRequest = getFakeKibanaRequest(context, spaceId, rawRule.attributes.apiKey); const rulesClient = context.getRulesClientWithRequest(fakeRequest); + const rule = rulesClient.getAlertFromRaw({ id: ruleId, ruleTypeId: rawRule.attributes.alertTypeId as string, @@ -133,7 +138,7 @@ export async function getRuleAttributes( return { rule, version: rawRule.version, - indirectParams: rawRule.attributes, + indirectParams: rawRule.attributes as SerializableRawRule, fakeRequest, rulesClient, }; diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts index 5a9d99e631082..719781eba8814 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts @@ -18,6 +18,7 @@ import { RuleAction, MaintenanceWindow, RuleAlertData, + RuleActionTypes, } from '../types'; import { ConcreteTaskInstance, isUnrecoverableError } from '@kbn/task-manager-plugin/server'; import { TaskRunnerContext } from './task_runner_factory'; @@ -1390,6 +1391,7 @@ describe('Task Runner', () => { foo: true, }, uuid: '111-111', + type: RuleActionTypes.DEFAULT, }, { group: recoveryActionGroup.id, @@ -1399,6 +1401,7 @@ describe('Task Runner', () => { isResolved: true, }, uuid: '222-222', + type: RuleActionTypes.DEFAULT, }, ], }); @@ -1486,6 +1489,7 @@ describe('Task Runner', () => { params: { foo: true, }, + type: RuleActionTypes.DEFAULT, uuid: '111-111', }, ], @@ -1549,6 +1553,7 @@ describe('Task Runner', () => { params: { foo: true, }, + type: RuleActionTypes.DEFAULT, uuid: '111-111', }, ], diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.ts index 6d3be52cf2e62..fae36d2fa2c7a 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.ts @@ -51,6 +51,7 @@ import { RuleAlertData, SanitizedRule, RuleNotifyWhen, + RuleActionTypes, } from '../../common'; import { NormalizedRuleType, UntypedNormalizedRuleType } from '../rule_type_registry'; import { getEsErrorMessage } from '../lib/errors'; @@ -549,7 +550,12 @@ export class TaskRunner< flappingSettings, notifyOnActionGroupChange: notifyWhen === RuleNotifyWhen.CHANGE || - some(actions, (action) => action.frequency?.notifyWhen === RuleNotifyWhen.CHANGE), + some( + actions, + (action) => + action.type !== RuleActionTypes.SYSTEM && + action.frequency?.notifyWhen === RuleNotifyWhen.CHANGE + ), maintenanceWindowIds, }); }); diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts index 41140b5b25df7..f7528eaffe654 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts @@ -111,6 +111,7 @@ describe('Task Runner Cancel', () => { const uiSettingsService = uiSettingsServiceMock.createStartContract(); const dataPlugin = dataPluginMock.createStartContract(); const inMemoryMetrics = inMemoryMetricsMock.create(); + const connectorAdapterRegistry = new ConnectorAdapterRegistry(); type TaskRunnerFactoryInitializerParamsType = jest.Mocked & { actionsPlugin: jest.Mocked; @@ -151,7 +152,7 @@ describe('Task Runner Cancel', () => { getMaintenanceWindowClientWithRequest: jest .fn() .mockReturnValue(maintenanceWindowClientMock.create()), - connectorAdapterRegistry: new ConnectorAdapterRegistry(), + connectorAdapterRegistry, }; beforeEach(() => { diff --git a/x-pack/plugins/alerting/server/types.ts b/x-pack/plugins/alerting/server/types.ts index bee42c98dc075..58a970ff74d4a 100644 --- a/x-pack/plugins/alerting/server/types.ts +++ b/x-pack/plugins/alerting/server/types.ts @@ -25,6 +25,7 @@ import { SharePluginStart } from '@kbn/share-plugin/server'; import type { FieldMap } from '@kbn/alerts-as-data-utils'; import { Alert } from '@kbn/alerts-as-data-utils'; import { Filter } from '@kbn/es-query'; +import { ActionsApiRequestHandlerContext } from '@kbn/actions-plugin/server'; import { RuleTypeRegistry as OrigruleTypeRegistry } from './rule_type_registry'; import { PluginSetupContract, PluginStartContract } from './plugin'; import { RulesClient } from './rules_client'; @@ -56,6 +57,7 @@ import { AlertsFilter, AlertsFilterTimeframe, RuleAlertData, + RuleActionResponse, } from '../common'; import { PublicAlertFactory } from './alert/create_alert_factory'; import { RulesSettingsFlappingProperties } from '../common/rules_settings'; @@ -80,6 +82,7 @@ export interface AlertingApiRequestHandlerContext { */ export type AlertingRequestHandlerContext = CustomRequestHandlerContext<{ alerting: AlertingApiRequestHandlerContext; + actions: ActionsApiRequestHandlerContext; }>; /** @@ -327,6 +330,18 @@ export interface RuleMeta extends SavedObjectAttributes { export type PartialRule = Pick, 'id'> & Partial, 'id'>>; +/** + * TODO: Remove when all http routes and methods + * of the rule clients are versioned. + * + * Actions internally (rules client methods) contains a type (RuleActionTypes). + * All APIs strip out the type from the actions. This TS type represents that. + */ +export type PartialRuleResponse = Omit< + PartialRule, + 'action' +> & { actions?: RuleActionResponse[] }; + export interface RuleWithLegacyId extends Rule { legacyId: string | null; } @@ -404,9 +419,9 @@ export interface RawRuleAlertsFilter extends AlertsFilter { timeframe?: AlertsFilterTimeframe; } -export interface RawRuleAction extends SavedObjectAttributes { +export interface RawRuleAction { uuid: string; - group: string; + group?: string; actionRef: string; actionTypeId: string; params: RuleActionParams; @@ -421,7 +436,7 @@ export interface RawRuleAction extends SavedObjectAttributes { // note that the `error` property is "null-able", as we're doing a partial // update on the rule when we update this data, but need to ensure we // delete any previous error if the current status has no error -export interface RawRuleExecutionStatus extends SavedObjectAttributes { +export interface RawRuleExecutionStatus { status: RuleExecutionStatuses; lastExecutionDate: string; lastDuration?: number; @@ -435,7 +450,7 @@ export interface RawRuleExecutionStatus extends SavedObjectAttributes { }; } -export interface RawRule extends SavedObjectAttributes { +export interface RawRule { enabled: boolean; name: string; tags: string[]; diff --git a/x-pack/plugins/alerting/tsconfig.json b/x-pack/plugins/alerting/tsconfig.json index f38fbd085c7f0..55f714f9e9729 100644 --- a/x-pack/plugins/alerting/tsconfig.json +++ b/x-pack/plugins/alerting/tsconfig.json @@ -58,6 +58,7 @@ "@kbn/core-http-server-mocks", "@kbn/serverless", "@kbn/core-http-router-server-mocks", + "@kbn/core-saved-objects-api-server-mocks", ], "exclude": ["target/**/*"] } diff --git a/x-pack/plugins/monitoring/public/alerts/alert_form.test.tsx b/x-pack/plugins/monitoring/public/alerts/alert_form.test.tsx index 0113391558854..a5bca5f0d53f4 100644 --- a/x-pack/plugins/monitoring/public/alerts/alert_form.test.tsx +++ b/x-pack/plugins/monitoring/public/alerts/alert_form.test.tsx @@ -21,6 +21,7 @@ import { Rule, GenericValidationResult, RuleTypeModel, + RuleAction, } from '@kbn/triggers-actions-ui-plugin/public/types'; import { RuleForm } from '@kbn/triggers-actions-ui-plugin/public/application/sections/rule_form/rule_form'; import ActionForm from '@kbn/triggers-actions-ui-plugin/public/application/sections/action_connector_form/action_form'; @@ -28,13 +29,6 @@ import { Legacy } from '../legacy_shims'; import { I18nProvider } from '@kbn/i18n-react'; import { createKibanaReactContext } from '@kbn/kibana-react-plugin/public'; -interface AlertAction { - group: string; - id: string; - actionTypeId: string; - params: unknown; -} - jest.mock('@kbn/triggers-actions-ui-plugin/public/application/lib/action_connector_api', () => ({ loadAllActions: jest.fn(), loadActionTypes: jest.fn(), @@ -240,7 +234,7 @@ describe('alert_form', () => { setActionIdByIndex={(id: string, index: number) => { initialAlert.actions[index].id = id; }} - setActions={(_updatedActions: AlertAction[]) => {}} + setActions={(_updatedActions: RuleAction[]) => {}} setActionParamsProperty={(key: string, value: unknown, index: number) => (initialAlert.actions[index] = { ...initialAlert.actions[index], [key]: value }) } diff --git a/x-pack/test/alerting_api_integration/common/config.ts b/x-pack/test/alerting_api_integration/common/config.ts index 823ef92ea32ca..5d814d6908007 100644 --- a/x-pack/test/alerting_api_integration/common/config.ts +++ b/x-pack/test/alerting_api_integration/common/config.ts @@ -67,6 +67,7 @@ const enabledActionTypes = [ 'test.capped', 'test.system-action', 'test.system-action-kibana-privileges', + 'test.system-action-connector-adapter', ]; export function createTestConfig(name: string, options: CreateTestConfigOptions) { diff --git a/x-pack/test/alerting_api_integration/common/lib/alert_utils.ts b/x-pack/test/alerting_api_integration/common/lib/alert_utils.ts index 5b4aeb496b125..9321b5d70452a 100644 --- a/x-pack/test/alerting_api_integration/common/lib/alert_utils.ts +++ b/x-pack/test/alerting_api_integration/common/lib/alert_utils.ts @@ -6,7 +6,7 @@ */ import { ES_TEST_INDEX_NAME } from '@kbn/alerting-api-integration-helpers'; -import { AlertsFilter } from '@kbn/alerting-plugin/common/rule'; +import { AlertsFilter, RuleActionTypes } from '@kbn/alerting-plugin/common/rule'; import { Space, User } from '../types'; import { ObjectRemover } from './object_remover'; import { getUrlPrefix } from './space_test_utils'; @@ -351,6 +351,36 @@ export class AlertUtils { return response; } + public async createAlwaysFiringSystemAction({ + objectRemover, + overwrites = {}, + reference, + }: CreateAlertWithActionOpts) { + const objRemover = objectRemover || this.objectRemover; + + if (!objRemover) { + throw new Error('objectRemover is required'); + } + + let request = this.supertestWithoutAuth + .post(`${getUrlPrefix(this.space.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo'); + + if (this.user) { + request = request.auth(this.user.username, this.user.password); + } + + const rule = getAlwaysFiringRuleWithSystemAction(reference); + + const response = await request.send({ ...rule, ...overwrites }); + + if (response.statusCode === 200) { + objRemover.add(this.space.id, response.body.id, 'rule', 'alerting'); + } + + return response; + } + public async updateAlwaysFiringAction({ alertId, actionId, @@ -667,3 +697,35 @@ function getPatternFiringRuleWithSummaryAction( ], }; } + +function getAlwaysFiringRuleWithSystemAction(reference: string) { + return { + enabled: true, + name: 'abc', + schedule: { interval: '1m' }, + tags: ['tag-A', 'tag-B'], + rule_type_id: 'test.always-firing-alert-as-data', + consumer: 'alertsFixture', + params: { + index: ES_TEST_INDEX_NAME, + reference, + }, + actions: [ + { + id: 'system-connector-test.system-action-connector-adapter', + actionTypeId: 'test.system-action-connector-adapter', + uuid: '123', + /** + * The injected param required by the action will be set by the corresponding + * connector adapter. Setting it here it will lead to a 400 error by the + * rules API as only the connector adapter can set the injected property. + * + * Adapter: x-pack/test/alerting_api_integration/common/plugins/alerts/server/connector_adapters.ts + * Connector type: x-pack/test/alerting_api_integration/common/plugins/alerts/server/action_types.ts + */ + params: { myParam: 'param from rule action', index: ES_TEST_INDEX_NAME, reference }, + type: RuleActionTypes.SYSTEM, + }, + ], + }; +} diff --git a/x-pack/test/alerting_api_integration/common/plugins/alerts/server/action_types.ts b/x-pack/test/alerting_api_integration/common/plugins/alerts/server/action_types.ts index a7d5dbc138ea4..f2753cb99a24a 100644 --- a/x-pack/test/alerting_api_integration/common/plugins/alerts/server/action_types.ts +++ b/x-pack/test/alerting_api_integration/common/plugins/alerts/server/action_types.ts @@ -80,6 +80,7 @@ export function defineActionTypes( */ actions.registerType(getSystemActionType()); actions.registerType(getSystemActionTypeWithKibanaPrivileges()); + actions.registerType(getSystemActionTypeWithConnectorAdapter()); /** Sub action framework */ @@ -484,3 +485,67 @@ function getSystemActionTypeWithKibanaPrivileges() { return result; } + +function getSystemActionTypeWithConnectorAdapter() { + const result: ActionType< + {}, + {}, + { myParam: string; injected: string; index?: string; reference?: string } + > = { + id: 'test.system-action-connector-adapter', + name: 'Test system action with a connector adapter set', + minimumLicenseRequired: 'platinum', + supportedFeatureIds: ['alerting'], + validate: { + params: { + /** + * The injected params will be set by the + * connector adapter while executing the action. + * + * Adapter: x-pack/test/alerting_api_integration/common/plugins/alerts/server/connector_adapters.ts + */ + schema: schema.object({ + myParam: schema.string(), + injected: schema.string(), + index: schema.maybe(schema.string()), + reference: schema.maybe(schema.string()), + }), + }, + + config: { + schema: schema.any(), + }, + secrets: { + schema: schema.any(), + }, + }, + isSystemActionType: true, + /** + * The executor writes a doc to the + * testing index. The test uses the doc + * to verify that the action is executed + * correctly + */ + async executor({ params, services, actionId }) { + const { index, reference } = params; + + if (index == null || reference == null) { + return { status: 'ok', actionId }; + } + + await services.scopedClusterClient.index({ + index, + refresh: 'wait_for', + body: { + params, + reference, + source: 'action:test.system-action-connector-adapter', + }, + }); + + return { status: 'ok', actionId }; + }, + }; + + return result; +} diff --git a/x-pack/test/alerting_api_integration/common/plugins/alerts/server/connector_adapters.ts b/x-pack/test/alerting_api_integration/common/plugins/alerts/server/connector_adapters.ts new file mode 100644 index 0000000000000..41526e0949de3 --- /dev/null +++ b/x-pack/test/alerting_api_integration/common/plugins/alerts/server/connector_adapters.ts @@ -0,0 +1,37 @@ +/* + * 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 { ConnectorAdapter } from '@kbn/alerting-plugin/server'; +import { CoreSetup } from '@kbn/core/server'; +import { schema } from '@kbn/config-schema'; +import { FixtureStartDeps, FixtureSetupDeps } from './plugin'; + +export function defineConnectorAdapters( + core: CoreSetup, + { alerting }: Pick +) { + const systemActionConnectorAdapter: ConnectorAdapter = { + connectorTypeId: 'test.system-action-connector-adapter', + ruleActionParamsSchema: schema.object({ + myParam: schema.string(), + index: schema.maybe(schema.string()), + reference: schema.maybe(schema.string()), + }), + /** + * The connector adapter will inject a new param property which is required + * by the action. The injected value cannot be set in the actions of the rule + * as the schema validation will thrown an error. Only through the connector + * adapter this value can be set. The tests are using the connector adapter test + * that the new property is injected correctly + */ + buildActionParams: ({ alerts, rule, params, spaceId, ruleUrl }) => { + return { ...params, injected: 'param from connector adapter' }; + }, + }; + + alerting.registerConnectorAdapter(systemActionConnectorAdapter); +} diff --git a/x-pack/test/alerting_api_integration/common/plugins/alerts/server/plugin.ts b/x-pack/test/alerting_api_integration/common/plugins/alerts/server/plugin.ts index 0809a4a5b71c7..ecd5a05618d04 100644 --- a/x-pack/test/alerting_api_integration/common/plugins/alerts/server/plugin.ts +++ b/x-pack/test/alerting_api_integration/common/plugins/alerts/server/plugin.ts @@ -27,6 +27,7 @@ import { NotificationsPluginStart } from '@kbn/notifications-plugin/server'; import { defineRoutes } from './routes'; import { defineActionTypes } from './action_types'; import { defineAlertTypes } from './alert_types'; +import { defineConnectorAdapters } from './connector_adapters'; export interface FixtureSetupDeps { features: FeaturesPluginSetup; @@ -159,6 +160,7 @@ export class FixturePlugin implements Plugin objectRemover.removeAll()); + after(async () => { await esTestIndexTool.destroy(); await es.indices.delete({ index: authorizationIndex }); @@ -1866,6 +1868,76 @@ instanceStateValue: true }); }); } + + describe('connector adapters', () => { + const space = SuperuserAtSpace1.space; + + const connectorId = 'system-connector-test.system-action-connector-adapter'; + const name = 'System action: test.system-action-connector-adapter'; + + it('should use connector adapters correctly on system actions', async () => { + const alertUtils = new AlertUtils({ + supertestWithoutAuth, + objectRemover, + space, + user: SuperuserAtSpace1.user, + }); + + const startDate = new Date().toISOString(); + const reference = alertUtils.generateReference(); + /** + * Creates a rule that always fire with a system action + * that has configured a connector adapter. + * + * System action: x-pack/test/alerting_api_integration/common/plugins/alerts/server/action_types.ts + * Adapter: x-pack/test/alerting_api_integration/common/plugins/alerts/server/connector_adapters.ts + */ + const response = await alertUtils.createAlwaysFiringSystemAction({ + reference, + overwrites: { schedule: { interval: '1s' } }, + }); + + expect(response.status).to.eql(200); + + await validateSystemActionEventLog({ + spaceId: space.id, + connectorId, + outcome: 'success', + message: `action executed: test.system-action-connector-adapter:${connectorId}: ${name}`, + startDate, + }); + + /** + * The executor function of the system action + * writes the params in the test index. We + * get the doc to verify that the connector adapter + * injected the param correctly. + */ + await esTestIndexTool.waitForDocs( + 'action:test.system-action-connector-adapter', + reference, + 1 + ); + + const docs = await esTestIndexTool.search( + 'action:test.system-action-connector-adapter', + reference + ); + + const doc = docs.body.hits.hits[0]._source as { params: Record }; + + expect(doc.params).to.eql({ + myParam: 'param from rule action', + index: '.kibana-alerting-test-data', + reference: 'alert-utils-ref:1:superuser', + /** + * Param was injected by the connector adapter in + * x-pack/test/alerting_api_integration/common/plugins/alerts/server/connector_adapters.ts + */ + injected: 'param from connector adapter', + }); + }); + }); }); interface ValidateEventLogParams { @@ -1970,4 +2042,46 @@ instanceStateValue: true expect(event?.error?.message).to.eql(errorMessage); } } + + interface ValidateSystemActionEventLogParams { + spaceId: string; + connectorId: string; + outcome: string; + message: string; + startDate: string; + errorMessage?: string; + } + + const validateSystemActionEventLog = async ( + params: ValidateSystemActionEventLogParams + ): Promise => { + const { spaceId, connectorId, outcome, message, startDate, errorMessage } = params; + + const events: IValidatedEvent[] = await retry.try(async () => { + const events_ = await getEventLog({ + getService, + spaceId, + type: 'action', + id: connectorId, + provider: 'actions', + actions: new Map([['execute', { gte: 1 }]]), + }); + + const filteredEvents = events_.filter((event) => event!['@timestamp']! >= startDate); + if (filteredEvents.length < 1) throw new Error('no recent events found yet'); + + return filteredEvents; + }); + + expect(events.length).to.be(1); + + const event = events[0]; + + expect(event?.message).to.eql(message); + expect(event?.event?.outcome).to.eql(outcome); + + if (errorMessage) { + expect(event?.error?.message).to.eql(errorMessage); + } + }; } diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group1/create.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group1/create.ts index f3e7d8891cde3..cd3e998fd6336 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group1/create.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group1/create.ts @@ -7,8 +7,9 @@ import expect from '@kbn/expect'; import { SavedObject } from '@kbn/core/server'; -import { RawRule } from '@kbn/alerting-plugin/server/types'; +import { RawRule, RuleActionTypes } from '@kbn/alerting-plugin/server/types'; import { ALERTING_CASES_SAVED_OBJECT_INDEX } from '@kbn/core-saved-objects-server'; +import { omit } from 'lodash'; import { Spaces } from '../../../scenarios'; import { checkAAD, @@ -159,8 +160,10 @@ export default function createAlertTests({ getService }: FtrProviderContext) { }, { id: 'system-connector-test.system-action', - group: 'default', + actionTypeId: 'test.system-action', + uuid: '123', params: {}, + type: RuleActionTypes.SYSTEM, }, ], }) @@ -193,10 +196,10 @@ export default function createAlertTests({ getService }: FtrProviderContext) { }, { id: 'system-connector-test.system-action', - group: 'default', connector_type_id: 'test.system-action', params: {}, - uuid: response.body.actions[2].uuid, + uuid: '123', + type: RuleActionTypes.SYSTEM, }, ], enabled: true, @@ -255,9 +258,9 @@ export default function createAlertTests({ getService }: FtrProviderContext) { { actionRef: 'system_action:system-connector-test.system-action', actionTypeId: 'test.system-action', - group: 'default', params: {}, - uuid: rawActions[2].uuid, + uuid: '123', + type: RuleActionTypes.SYSTEM, }, ]); @@ -464,6 +467,133 @@ export default function createAlertTests({ getService }: FtrProviderContext) { expect(response.body.scheduledTaskId).to.eql(undefined); }); + describe('system actions', () => { + const systemAction = { + id: 'system-connector-test.system-action', + actionTypeId: 'test.system-action', + uuid: '123', + params: {}, + type: RuleActionTypes.SYSTEM, + }; + + it('should create a rule with a system action correctly', async () => { + const response = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + actions: [systemAction], + }) + ); + + expect(response.status).to.eql(200); + + objectRemover.add(Spaces.space1.id, response.body.id, 'rule', 'alerting'); + + expect(response.body.actions).to.eql([ + { + id: 'system-connector-test.system-action', + connector_type_id: 'test.system-action', + params: {}, + uuid: '123', + type: RuleActionTypes.SYSTEM, + }, + ]); + + const esResponse = await es.get>( + { + index: ALERTING_CASES_SAVED_OBJECT_INDEX, + id: `alert:${response.body.id}`, + }, + { meta: true } + ); + + expect(esResponse.statusCode).to.eql(200); + const rawActions = (esResponse.body._source as any)?.alert.actions ?? []; + + expect(rawActions).to.eql([ + { + actionRef: 'system_action:system-connector-test.system-action', + actionTypeId: 'test.system-action', + params: {}, + uuid: '123', + type: RuleActionTypes.SYSTEM, + }, + ]); + + const references = esResponse.body._source?.references ?? []; + + expect(references.length).to.eql(0); + }); + + it('should throw 400 if the system action is missing required properties', async () => { + for (const propertyToOmit of ['id', 'actionTypeId', 'uuid']) { + const systemActionWithoutProperty = omit(systemAction, propertyToOmit); + + await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + actions: [systemActionWithoutProperty], + }) + ) + .expect(400); + } + }); + + it('should throw 400 if the system action contain properties from the default actions', async () => { + for (const propertyAdd of [ + { group: 'test' }, + { + frequency: { + notify_when: 'onThrottleInterval' as const, + summary: true, + throttle: '1h', + }, + }, + { + alerts_filter: { + query: { dsl: '{test:1}', kql: 'test:1s', filters: [] }, + }, + }, + ]) { + await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + actions: [{ ...systemAction, ...propertyAdd }], + }) + ) + .expect(400); + } + }); + + it('should throw 400 if the system action is missing required params', async () => { + const res = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + actions: [ + { + ...systemAction, + params: {}, + id: 'system-connector-test.system-action-connector-adapter', + actionTypeId: 'test.test.system-action-connector-adapter', + }, + ], + }) + ) + .expect(400); + + expect(res.body.message).to.eql( + 'Invalid system action params. System action type: test.system-action-connector-adapter - [myParam]: expected value of type [string] but got [undefined]' + ); + }); + }); + describe('legacy', () => { it('should handle create alert request appropriately', async () => { const { body: createdAction } = await supertest diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group2/bulk_edit.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group2/bulk_edit.ts new file mode 100644 index 0000000000000..0b1ed0f6ec38f --- /dev/null +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group2/bulk_edit.ts @@ -0,0 +1,181 @@ +/* + * 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 expect from '@kbn/expect'; +import { RuleActionTypes } from '@kbn/alerting-plugin/common'; +import { omit } from 'lodash'; +import { Spaces } from '../../../scenarios'; +import { getUrlPrefix, getTestRuleData, ObjectRemover } from '../../../../common/lib'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function createUpdateTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + + describe('bulkEdit', () => { + const objectRemover = new ObjectRemover(supertest); + + after(() => objectRemover.removeAll()); + + describe('system actions', () => { + const systemAction = { + id: 'system-connector-test.system-action', + uuid: '123', + params: {}, + type: RuleActionTypes.SYSTEM, + }; + + it('should bulk edit system actions correctly', async () => { + const { body: rule } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getTestRuleData()) + .expect(200); + + objectRemover.add(Spaces.space1.id, rule.id, 'rule', 'alerting'); + + const payload = { + ids: [rule.id], + operations: [ + { + operation: 'add', + field: 'actions', + value: [systemAction], + }, + ], + }; + + const res = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rules/_bulk_edit`) + .set('kbn-xsrf', 'foo') + .send(payload) + .expect(200); + + expect(res.body.rules[0].actions).to.eql([ + { + id: 'system-connector-test.system-action', + connector_type_id: 'test.system-action', + params: {}, + uuid: '123', + type: RuleActionTypes.SYSTEM, + }, + ]); + }); + + it('should throw 400 if the system action is missing required properties', async () => { + const { body: rule } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getTestRuleData()) + .expect(200); + + objectRemover.add(Spaces.space1.id, rule.id, 'rule', 'alerting'); + + for (const propertyToOmit of ['id', 'uuid']) { + const systemActionWithoutProperty = omit(systemAction, propertyToOmit); + + const payload = { + ids: [rule.id], + operations: [ + { + operation: 'add', + field: 'actions', + value: [systemActionWithoutProperty], + }, + ], + }; + + await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rules/_bulk_edit`) + .set('kbn-xsrf', 'foo') + .send(payload) + .expect(400); + } + }); + + it('should throw 400 if the system action contain properties from the default actions', async () => { + const { body: rule } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getTestRuleData()) + .expect(200); + + objectRemover.add(Spaces.space1.id, rule.id, 'rule', 'alerting'); + + for (const propertyAdd of [ + { group: 'test' }, + { + frequency: { + notify_when: 'onThrottleInterval' as const, + summary: true, + throttle: '1h', + }, + }, + { + alerts_filter: { + query: { dsl: '{test:1}', kql: 'test:1s', filters: [] }, + }, + }, + ]) { + const payload = { + ids: [rule.id], + operations: [ + { + operation: 'add', + field: 'actions', + value: [{ ...systemAction, ...propertyAdd }], + }, + ], + }; + + await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rules/_bulk_edit`) + .set('kbn-xsrf', 'foo') + .send(payload) + .expect(400); + } + }); + + it('should throw 400 if the system action is missing required params', async () => { + const { body: rule } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getTestRuleData()) + .expect(200); + + objectRemover.add(Spaces.space1.id, rule.id, 'rule', 'alerting'); + + const payload = { + ids: [rule.id], + operations: [ + { + operation: 'add', + field: 'actions', + value: [ + { + ...systemAction, + params: {}, + id: 'system-connector-test.system-action-connector-adapter', + }, + ], + }, + ], + }; + + const res = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rules/_bulk_edit`) + .set('kbn-xsrf', 'foo') + .send(payload) + .expect(200); + + expect(res.body.errors[0].message).to.eql( + 'Invalid system action params. System action type: test.system-action-connector-adapter - [myParam]: expected value of type [string] but got [undefined]' + ); + }); + }); + }); +} diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group2/index.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group2/index.ts index 1ccbb1c8f722d..5b03010a60235 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group2/index.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group2/index.ts @@ -27,5 +27,6 @@ export default function alertingTests({ loadTestFile, getService }: FtrProviderC loadTestFile(require.resolve('./alerts_default_space')); loadTestFile(require.resolve('./transform_rule_types')); loadTestFile(require.resolve('./ml_rule_types')); + loadTestFile(require.resolve('./bulk_edit')); }); } diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group2/update.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group2/update.ts index c7dc4e1484580..a996ad2514782 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group2/update.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group2/update.ts @@ -6,6 +6,8 @@ */ import expect from '@kbn/expect'; +import { RuleActionTypes } from '@kbn/alerting-plugin/common'; +import { omit } from 'lodash'; import { Spaces } from '../../../scenarios'; import { checkAAD, getUrlPrefix, getTestRuleData, ObjectRemover } from '../../../../common/lib'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; @@ -127,6 +129,131 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { }); }); + describe('system actions', () => { + const systemAction = { + id: 'system-connector-test.system-action', + uuid: '123', + params: {}, + type: RuleActionTypes.SYSTEM, + }; + + it('should update a rule with a system action correctly', async () => { + const { body: rule } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getTestRuleData()) + .expect(200); + + objectRemover.add(Spaces.space1.id, rule.id, 'rule', 'alerting'); + + const res = await supertest + .put(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule/${rule.id}`) + .set('kbn-xsrf', 'foo') + .send({ name: rule.name, schedule: rule.schedule, actions: [systemAction] }) + .expect(200); + + expect(res.body.actions).to.eql([ + { + id: 'system-connector-test.system-action', + connector_type_id: 'test.system-action', + params: {}, + uuid: '123', + type: RuleActionTypes.SYSTEM, + }, + ]); + }); + + it('should throw 400 if the system action is missing required properties', async () => { + const { body: rule } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getTestRuleData()) + .expect(200); + + objectRemover.add(Spaces.space1.id, rule.id, 'rule', 'alerting'); + + for (const propertyToOmit of ['id', 'uuid']) { + const systemActionWithoutProperty = omit(systemAction, propertyToOmit); + + await supertest + .put(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule/${rule.id}`) + .set('kbn-xsrf', 'foo') + .send({ + name: rule.name, + schedule: rule.schedule, + actions: [systemActionWithoutProperty], + }) + .expect(400); + } + }); + + it('should throw 400 if the system action contain properties from the default actions', async () => { + const { body: rule } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getTestRuleData()) + .expect(200); + + objectRemover.add(Spaces.space1.id, rule.id, 'rule', 'alerting'); + + for (const propertyAdd of [ + { group: 'test' }, + { + frequency: { + notify_when: 'onThrottleInterval' as const, + summary: true, + throttle: '1h', + }, + }, + { + alerts_filter: { + query: { dsl: '{test:1}', kql: 'test:1s', filters: [] }, + }, + }, + ]) { + await supertest + .put(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule/${rule.id}`) + .set('kbn-xsrf', 'foo') + .send({ + name: rule.name, + schedule: rule.schedule, + actions: [{ ...systemAction, ...propertyAdd }], + }) + .expect(400); + } + }); + + it('should throw 400 if the system action is missing required params', async () => { + const { body: rule } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getTestRuleData()) + .expect(200); + + objectRemover.add(Spaces.space1.id, rule.id, 'rule', 'alerting'); + + const res = await supertest + .put(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule/${rule.id}`) + .set('kbn-xsrf', 'foo') + .send({ + name: rule.name, + schedule: rule.schedule, + actions: [ + { + ...systemAction, + params: {}, + id: 'system-connector-test.system-action-connector-adapter', + }, + ], + }) + .expect(400); + + expect(res.body.message).to.eql( + 'Invalid system action params. System action type: test.system-action-connector-adapter - [myParam]: expected value of type [string] but got [undefined]' + ); + }); + }); + describe('legacy', () => { it('should handle update alert request appropriately', async () => { const { body: createdAlert } = await supertest