diff --git a/x-pack/plugins/alerting/common/alert.ts b/x-pack/plugins/alerting/common/alert.ts index 64c759752faec..1274e7b95b114 100644 --- a/x-pack/plugins/alerting/common/alert.ts +++ b/x-pack/plugins/alerting/common/alert.ts @@ -5,7 +5,11 @@ * 2.0. */ -import { SavedObjectAttribute, SavedObjectAttributes } from 'kibana/server'; +import { + SavedObjectAttribute, + SavedObjectAttributes, + SavedObjectsResolveResponse, +} from 'kibana/server'; import { AlertNotifyWhenType } from './alert_notify_when_type'; export type AlertTypeState = Record; @@ -76,6 +80,8 @@ export interface Alert { } export type SanitizedAlert = Omit, 'apiKey'>; +export type ResolvedSanitizedRule = SanitizedAlert & + Omit; export type SanitizedRuleConfig = Pick< SanitizedAlert, diff --git a/x-pack/plugins/alerting/server/routes/index.ts b/x-pack/plugins/alerting/server/routes/index.ts index d26f898b67cad..703cce7517665 100644 --- a/x-pack/plugins/alerting/server/routes/index.ts +++ b/x-pack/plugins/alerting/server/routes/index.ts @@ -22,6 +22,7 @@ import { findRulesRoute } from './find_rules'; import { getRuleAlertSummaryRoute } from './get_rule_alert_summary'; import { getRuleStateRoute } from './get_rule_state'; import { healthRoute } from './health'; +import { resolveRuleRoute } from './resolve_rule'; import { ruleTypesRoute } from './rule_types'; import { muteAllRuleRoute } from './mute_all_rule'; import { muteAlertRoute } from './mute_alert'; @@ -42,6 +43,7 @@ export function defineRoutes(opts: RouteOptions) { defineLegacyRoutes(opts); createRuleRoute(opts); getRuleRoute(router, licenseState); + resolveRuleRoute(router, licenseState); updateRuleRoute(router, licenseState); deleteRuleRoute(router, licenseState); aggregateRulesRoute(router, licenseState); diff --git a/x-pack/plugins/alerting/server/routes/resolve_rule.test.ts b/x-pack/plugins/alerting/server/routes/resolve_rule.test.ts new file mode 100644 index 0000000000000..b03369a74b865 --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/resolve_rule.test.ts @@ -0,0 +1,182 @@ +/* + * 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 { pick } from 'lodash'; +import { resolveRuleRoute } from './resolve_rule'; +import { httpServiceMock } from 'src/core/server/mocks'; +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 { AsApiContract } from './lib'; + +const rulesClient = rulesClientMock.create(); +jest.mock('../lib/license_api_access.ts', () => ({ + verifyApiAccess: jest.fn(), +})); + +beforeEach(() => { + jest.resetAllMocks(); +}); + +describe('resolveRuleRoute', () => { + const mockedRule: ResolvedSanitizedRule<{ + bar: boolean; + }> = { + id: '1', + alertTypeId: '1', + schedule: { interval: '10s' }, + params: { + bar: true, + }, + createdAt: new Date(), + updatedAt: new Date(), + actions: [ + { + group: 'default', + id: '2', + actionTypeId: 'test', + params: { + foo: true, + }, + }, + ], + consumer: 'bar', + name: 'abc', + tags: ['foo'], + enabled: true, + muteAll: false, + notifyWhen: 'onActionGroupChange', + createdBy: '', + updatedBy: '', + apiKeyOwner: '', + throttle: '30s', + mutedInstanceIds: [], + executionStatus: { + status: 'unknown', + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + }, + outcome: 'aliasMatch', + alias_target_id: '2', + }; + + const resolveResult: AsApiContract> = { + ...pick( + mockedRule, + 'consumer', + 'name', + 'schedule', + 'tags', + 'params', + 'throttle', + 'enabled', + 'alias_target_id' + ), + rule_type_id: mockedRule.alertTypeId, + notify_when: mockedRule.notifyWhen, + mute_all: mockedRule.muteAll, + created_by: mockedRule.createdBy, + updated_by: mockedRule.updatedBy, + api_key_owner: mockedRule.apiKeyOwner, + muted_alert_ids: mockedRule.mutedInstanceIds, + created_at: mockedRule.createdAt, + updated_at: mockedRule.updatedAt, + id: mockedRule.id, + execution_status: { + status: mockedRule.executionStatus.status, + last_execution_date: mockedRule.executionStatus.lastExecutionDate, + }, + actions: [ + { + group: mockedRule.actions[0].group, + id: mockedRule.actions[0].id, + params: mockedRule.actions[0].params, + connector_type_id: mockedRule.actions[0].actionTypeId, + }, + ], + outcome: 'aliasMatch', + }; + + it('resolves a rule with proper parameters', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + resolveRuleRoute(router, licenseState); + const [config, handler] = router.get.mock.calls[0]; + + expect(config.path).toMatchInlineSnapshot(`"/internal/alerting/rule/{id}/_resolve"`); + + rulesClient.resolve.mockResolvedValueOnce(mockedRule); + + const [context, req, res] = mockHandlerArguments( + { rulesClient }, + { + params: { id: '1' }, + }, + ['ok'] + ); + await handler(context, req, res); + + expect(rulesClient.resolve).toHaveBeenCalledTimes(1); + expect(rulesClient.resolve.mock.calls[0][0].id).toEqual('1'); + + expect(res.ok).toHaveBeenCalledWith({ + body: resolveResult, + }); + }); + + it('ensures the license allows resolving rules', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + resolveRuleRoute(router, licenseState); + + const [, handler] = router.get.mock.calls[0]; + + rulesClient.resolve.mockResolvedValueOnce(mockedRule); + + const [context, req, res] = mockHandlerArguments( + { rulesClient }, + { + params: { id: '1' }, + }, + ['ok'] + ); + + await handler(context, req, res); + + expect(verifyApiAccess).toHaveBeenCalledWith(licenseState); + }); + + it('ensures the license check prevents getting rules', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + (verifyApiAccess as jest.Mock).mockImplementation(() => { + throw new Error('OMG'); + }); + + resolveRuleRoute(router, licenseState); + + const [, handler] = router.get.mock.calls[0]; + + rulesClient.resolve.mockResolvedValueOnce(mockedRule); + + const [context, req, res] = mockHandlerArguments( + { rulesClient }, + { + params: { id: '1' }, + }, + ['ok'] + ); + + expect(handler(context, req, res)).rejects.toMatchInlineSnapshot(`[Error: OMG]`); + + expect(verifyApiAccess).toHaveBeenCalledWith(licenseState); + }); +}); diff --git a/x-pack/plugins/alerting/server/routes/resolve_rule.ts b/x-pack/plugins/alerting/server/routes/resolve_rule.ts new file mode 100644 index 0000000000000..011d28780e718 --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/resolve_rule.ts @@ -0,0 +1,84 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { IRouter } from 'kibana/server'; +import { ILicenseState } from '../lib'; +import { verifyAccessAndContext, RewriteResponseCase } from './lib'; +import { + AlertTypeParams, + AlertingRequestHandlerContext, + INTERNAL_BASE_ALERTING_API_PATH, + ResolvedSanitizedRule, +} from '../types'; + +const paramSchema = schema.object({ + id: schema.string(), +}); + +const rewriteBodyRes: RewriteResponseCase> = ({ + alertTypeId, + createdBy, + updatedBy, + createdAt, + updatedAt, + apiKeyOwner, + notifyWhen, + muteAll, + mutedInstanceIds, + executionStatus, + actions, + scheduledTaskId, + ...rest +}) => ({ + ...rest, + rule_type_id: alertTypeId, + created_by: createdBy, + updated_by: updatedBy, + created_at: createdAt, + updated_at: updatedAt, + api_key_owner: apiKeyOwner, + notify_when: notifyWhen, + mute_all: muteAll, + muted_alert_ids: mutedInstanceIds, + scheduled_task_id: scheduledTaskId, + execution_status: executionStatus && { + ...omit(executionStatus, 'lastExecutionDate'), + last_execution_date: executionStatus.lastExecutionDate, + }, + actions: actions.map(({ group, id, actionTypeId, params }) => ({ + group, + id, + params, + connector_type_id: actionTypeId, + })), +}); + +export const resolveRuleRoute = ( + router: IRouter, + licenseState: ILicenseState +) => { + router.get( + { + path: `${INTERNAL_BASE_ALERTING_API_PATH}/rule/{id}/_resolve`, + validate: { + params: paramSchema, + }, + }, + router.handleLegacyErrors( + verifyAccessAndContext(licenseState, async function (context, req, res) { + const rulesClient = context.alerting.getRulesClient(); + const { id } = req.params; + const rule = await rulesClient.resolve({ id }); + return res.ok({ + body: rewriteBodyRes(rule), + }); + }) + ) + ); +}; diff --git a/x-pack/plugins/alerting/server/rules_client.mock.ts b/x-pack/plugins/alerting/server/rules_client.mock.ts index 4bd197e51a5da..438331a1cd580 100644 --- a/x-pack/plugins/alerting/server/rules_client.mock.ts +++ b/x-pack/plugins/alerting/server/rules_client.mock.ts @@ -16,6 +16,7 @@ const createRulesClientMock = () => { aggregate: jest.fn(), create: jest.fn(), get: jest.fn(), + resolve: jest.fn(), getAlertState: jest.fn(), find: jest.fn(), delete: jest.fn(), diff --git a/x-pack/plugins/alerting/server/rules_client/audit_events.ts b/x-pack/plugins/alerting/server/rules_client/audit_events.ts index f04b7c3701974..5f6122458ddaf 100644 --- a/x-pack/plugins/alerting/server/rules_client/audit_events.ts +++ b/x-pack/plugins/alerting/server/rules_client/audit_events.ts @@ -11,6 +11,7 @@ import { AuditEvent } from '../../../security/server'; export enum RuleAuditAction { CREATE = 'rule_create', GET = 'rule_get', + RESOLVE = 'rule_resolve', UPDATE = 'rule_update', UPDATE_API_KEY = 'rule_update_api_key', ENABLE = 'rule_enable', @@ -28,6 +29,7 @@ type VerbsTuple = [string, string, string]; const eventVerbs: Record = { rule_create: ['create', 'creating', 'created'], rule_get: ['access', 'accessing', 'accessed'], + rule_resolve: ['access', 'accessing', 'accessed'], rule_update: ['update', 'updating', 'updated'], rule_update_api_key: ['update API key of', 'updating API key of', 'updated API key of'], rule_enable: ['enable', 'enabling', 'enabled'], @@ -43,6 +45,7 @@ const eventVerbs: Record = { const eventTypes: Record = { rule_create: 'creation', rule_get: 'access', + rule_resolve: 'access', rule_update: 'change', rule_update_api_key: 'change', rule_enable: 'change', diff --git a/x-pack/plugins/alerting/server/rules_client/rules_client.ts b/x-pack/plugins/alerting/server/rules_client/rules_client.ts index a079a52448e2d..4d191584592a2 100644 --- a/x-pack/plugins/alerting/server/rules_client/rules_client.ts +++ b/x-pack/plugins/alerting/server/rules_client/rules_client.ts @@ -33,6 +33,7 @@ import { AlertExecutionStatusValues, AlertNotifyWhenType, AlertTypeParams, + ResolvedSanitizedRule, } from '../types'; import { validateAlertTypeParams, @@ -411,6 +412,52 @@ export class RulesClient { ); } + public async resolve({ + id, + }: { + id: string; + }): Promise> { + const { + saved_object: result, + ...resolveResponse + } = await this.unsecuredSavedObjectsClient.resolve('alert', id); + try { + await this.authorization.ensureAuthorized({ + ruleTypeId: result.attributes.alertTypeId, + consumer: result.attributes.consumer, + operation: ReadOperations.Get, + entity: AlertingAuthorizationEntity.Rule, + }); + } catch (error) { + this.auditLogger?.log( + ruleAuditEvent({ + action: RuleAuditAction.RESOLVE, + savedObject: { type: 'alert', id }, + error, + }) + ); + throw error; + } + this.auditLogger?.log( + ruleAuditEvent({ + action: RuleAuditAction.RESOLVE, + savedObject: { type: 'alert', id }, + }) + ); + + const rule = this.getAlertFromRaw( + result.id, + result.attributes.alertTypeId, + result.attributes, + result.references + ); + + return { + ...rule, + ...resolveResponse, + }; + } + public async getAlertState({ id }: { id: string }): Promise { const alert = await this.get({ id }); await this.authorization.ensureAuthorized({ 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 new file mode 100644 index 0000000000000..63feb4ff3147a --- /dev/null +++ b/x-pack/plugins/alerting/server/rules_client/tests/resolve.test.ts @@ -0,0 +1,451 @@ +/* + * 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 { RulesClient, ConstructorOptions } from '../rules_client'; +import { savedObjectsClientMock, loggingSystemMock } from '../../../../../../src/core/server/mocks'; +import { taskManagerMock } from '../../../../task_manager/server/mocks'; +import { ruleTypeRegistryMock } from '../../rule_type_registry.mock'; +import { alertingAuthorizationMock } from '../../authorization/alerting_authorization.mock'; +import { encryptedSavedObjectsMock } from '../../../../encrypted_saved_objects/server/mocks'; +import { actionsAuthorizationMock } from '../../../../actions/server/mocks'; +import { AlertingAuthorization } from '../../authorization/alerting_authorization'; +import { ActionsAuthorization } from '../../../../actions/server'; +import { httpServerMock } from '../../../../../../src/core/server/mocks'; +import { auditServiceMock } from '../../../../security/server/audit/index.mock'; +import { getBeforeSetup, setGlobalDate } from './lib'; +import { RecoveredActionGroup } from '../../../common'; + +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 auditLogger = auditServiceMock.create().asScoped(httpServerMock.createKibanaRequest()); + +const kibanaVersion = 'v7.10.0'; +const rulesClientParams: jest.Mocked = { + taskManager, + ruleTypeRegistry, + unsecuredSavedObjectsClient, + authorization: (authorization as unknown) as AlertingAuthorization, + actionsAuthorization: (actionsAuthorization as unknown) as ActionsAuthorization, + spaceId: 'default', + namespace: 'default', + getUserName: jest.fn(), + createAPIKey: jest.fn(), + logger: loggingSystemMock.create().get(), + encryptedSavedObjectsClient: encryptedSavedObjects, + getActionsClient: jest.fn(), + getEventLogClient: jest.fn(), + kibanaVersion, +}; + +beforeEach(() => { + getBeforeSetup(rulesClientParams, taskManager, ruleTypeRegistry); + (auditLogger.log as jest.Mock).mockClear(); +}); + +setGlobalDate(); + +describe('resolve()', () => { + test('calls saved objects client with given params', async () => { + const rulesClient = new RulesClient(rulesClientParams); + unsecuredSavedObjectsClient.resolve.mockResolvedValueOnce({ + saved_object: { + id: '1', + type: 'alert', + attributes: { + alertTypeId: '123', + schedule: { interval: '10s' }, + params: { + bar: true, + }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + actions: [ + { + group: 'default', + actionRef: 'action_0', + params: { + foo: true, + }, + }, + ], + notifyWhen: 'onActiveAlert', + }, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + ], + }, + outcome: 'aliasMatch', + alias_target_id: '2', + }); + const result = await rulesClient.resolve({ id: '1' }); + expect(result).toMatchInlineSnapshot(` + Object { + "actions": Array [ + Object { + "group": "default", + "id": "1", + "params": Object { + "foo": true, + }, + }, + ], + "alertTypeId": "123", + "alias_target_id": "2", + "createdAt": 2019-02-12T21:01:22.479Z, + "id": "1", + "notifyWhen": "onActiveAlert", + "outcome": "aliasMatch", + "params": Object { + "bar": true, + }, + "schedule": Object { + "interval": "10s", + }, + "updatedAt": 2019-02-12T21:01:22.479Z, + } + `); + expect(unsecuredSavedObjectsClient.resolve).toHaveBeenCalledTimes(1); + expect(unsecuredSavedObjectsClient.resolve.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "alert", + "1", + ] + `); + }); + + test('should call useSavedObjectReferences.injectReferences if defined for rule type', async () => { + const injectReferencesFn = jest.fn().mockReturnValue({ + bar: true, + parameterThatIsSavedObjectId: '9', + }); + ruleTypeRegistry.get.mockImplementation(() => ({ + id: '123', + name: 'Test', + actionGroups: [{ id: 'default', name: 'Default' }], + recoveryActionGroup: RecoveredActionGroup, + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + async executor() {}, + producer: 'alerts', + useSavedObjectReferences: { + extractReferences: jest.fn(), + injectReferences: injectReferencesFn, + }, + })); + const rulesClient = new RulesClient(rulesClientParams); + unsecuredSavedObjectsClient.resolve.mockResolvedValueOnce({ + saved_object: { + id: '1', + type: 'alert', + attributes: { + alertTypeId: '123', + schedule: { interval: '10s' }, + params: { + bar: true, + parameterThatIsSavedObjectRef: 'soRef_0', + }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + actions: [ + { + group: 'default', + actionRef: 'action_0', + params: { + foo: true, + }, + }, + ], + notifyWhen: 'onActiveAlert', + }, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + { + name: 'param:soRef_0', + type: 'someSavedObjectType', + id: '9', + }, + ], + }, + outcome: 'aliasMatch', + alias_target_id: '2', + }); + const result = await rulesClient.resolve({ id: '1' }); + + expect(injectReferencesFn).toHaveBeenCalledWith( + { + bar: true, + parameterThatIsSavedObjectRef: 'soRef_0', + }, + [{ id: '9', name: 'soRef_0', type: 'someSavedObjectType' }] + ); + expect(result).toMatchInlineSnapshot(` + Object { + "actions": Array [ + Object { + "group": "default", + "id": "1", + "params": Object { + "foo": true, + }, + }, + ], + "alertTypeId": "123", + "alias_target_id": "2", + "createdAt": 2019-02-12T21:01:22.479Z, + "id": "1", + "notifyWhen": "onActiveAlert", + "outcome": "aliasMatch", + "params": Object { + "bar": true, + "parameterThatIsSavedObjectId": "9", + }, + "schedule": Object { + "interval": "10s", + }, + "updatedAt": 2019-02-12T21:01:22.479Z, + } + `); + }); + + test(`throws an error when references aren't found`, async () => { + const rulesClient = new RulesClient(rulesClientParams); + unsecuredSavedObjectsClient.resolve.mockResolvedValueOnce({ + saved_object: { + id: '1', + type: 'alert', + attributes: { + alertTypeId: '123', + schedule: { interval: '10s' }, + params: { + bar: true, + }, + actions: [ + { + group: 'default', + actionRef: 'action_0', + params: { + foo: true, + }, + }, + ], + }, + references: [], + }, + outcome: 'aliasMatch', + alias_target_id: '2', + }); + await expect(rulesClient.resolve({ id: '1' })).rejects.toThrowErrorMatchingInlineSnapshot( + `"Action reference \\"action_0\\" not found in alert id: 1"` + ); + }); + + test('throws an error if useSavedObjectReferences.injectReferences throws an error', async () => { + const injectReferencesFn = jest.fn().mockImplementation(() => { + throw new Error('something went wrong!'); + }); + ruleTypeRegistry.get.mockImplementation(() => ({ + id: '123', + name: 'Test', + actionGroups: [{ id: 'default', name: 'Default' }], + recoveryActionGroup: RecoveredActionGroup, + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + async executor() {}, + producer: 'alerts', + useSavedObjectReferences: { + extractReferences: jest.fn(), + injectReferences: injectReferencesFn, + }, + })); + const rulesClient = new RulesClient(rulesClientParams); + unsecuredSavedObjectsClient.resolve.mockResolvedValueOnce({ + saved_object: { + id: '1', + type: 'alert', + attributes: { + alertTypeId: '123', + schedule: { interval: '10s' }, + params: { + bar: true, + parameterThatIsSavedObjectRef: 'soRef_0', + }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + actions: [ + { + group: 'default', + actionRef: 'action_0', + params: { + foo: true, + }, + }, + ], + notifyWhen: 'onActiveAlert', + }, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + { + name: 'soRef_0', + type: 'someSavedObjectType', + id: '9', + }, + ], + }, + 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!"` + ); + }); + + describe('authorization', () => { + beforeEach(() => { + unsecuredSavedObjectsClient.resolve.mockResolvedValueOnce({ + saved_object: { + id: '1', + type: 'alert', + attributes: { + alertTypeId: 'myType', + consumer: 'myApp', + schedule: { interval: '10s' }, + params: { + bar: true, + }, + actions: [ + { + group: 'default', + actionRef: 'action_0', + params: { + foo: true, + }, + }, + ], + }, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + ], + }, + outcome: 'aliasMatch', + alias_target_id: '2', + }); + }); + + test('ensures user is authorised to resolve this type of rule under the consumer', async () => { + const rulesClient = new RulesClient(rulesClientParams); + await rulesClient.resolve({ id: '1' }); + + expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ + entity: 'rule', + consumer: 'myApp', + operation: 'get', + ruleTypeId: 'myType', + }); + }); + + test('throws when user is not authorised to get this type of alert', async () => { + const rulesClient = new RulesClient(rulesClientParams); + authorization.ensureAuthorized.mockRejectedValue( + new Error(`Unauthorized to get a "myType" alert for "myApp"`) + ); + + await expect(rulesClient.resolve({ id: '1' })).rejects.toMatchInlineSnapshot( + `[Error: Unauthorized to get a "myType" alert for "myApp"]` + ); + + expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ + entity: 'rule', + consumer: 'myApp', + operation: 'get', + ruleTypeId: 'myType', + }); + }); + }); + + describe('auditLogger', () => { + beforeEach(() => { + unsecuredSavedObjectsClient.resolve.mockResolvedValueOnce({ + saved_object: { + id: '1', + type: 'alert', + attributes: { + alertTypeId: '123', + schedule: { interval: '10s' }, + params: { + bar: true, + }, + actions: [], + }, + references: [], + }, + outcome: 'aliasMatch', + alias_target_id: '2', + }); + }); + + test('logs audit event when getting a rule', async () => { + const rulesClient = new RulesClient({ ...rulesClientParams, auditLogger }); + await rulesClient.resolve({ id: '1' }); + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'rule_resolve', + outcome: 'success', + }), + kibana: { saved_object: { id: '1', type: 'alert' } }, + }) + ); + }); + + test('logs audit event when not authorised to get a rule', async () => { + const rulesClient = new RulesClient({ ...rulesClientParams, auditLogger }); + authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized')); + + await expect(rulesClient.resolve({ id: '1' })).rejects.toThrow(); + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'rule_resolve', + outcome: 'failure', + }), + kibana: { + saved_object: { + id: '1', + type: 'alert', + }, + }, + error: { + code: 'Error', + message: 'Unauthorized', + }, + }) + ); + }); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/kibana.json b/x-pack/plugins/triggers_actions_ui/kibana.json index 4033889d9811e..9e9af347af2a2 100644 --- a/x-pack/plugins/triggers_actions_ui/kibana.json +++ b/x-pack/plugins/triggers_actions_ui/kibana.json @@ -8,7 +8,7 @@ "server": true, "ui": true, "optionalPlugins": ["alerting", "features", "home", "spaces"], - "requiredPlugins": ["management", "charts", "data", "kibanaReact", "kibanaUtils", "savedObjects"], + "requiredPlugins": ["management", "charts", "data", "kibanaReact", "kibanaUtils", "savedObjects", "spacesOss"], "configPath": ["xpack", "trigger_actions_ui"], "extraPublicDirs": ["public/common", "public/common/constants"], "requiredBundles": ["home", "alerting", "esUiShared", "kibanaReact", "kibanaUtils"] diff --git a/x-pack/plugins/triggers_actions_ui/public/application/app.tsx b/x-pack/plugins/triggers_actions_ui/public/application/app.tsx index 9786f5dcb949d..cc34f1beaf6b9 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/app.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/app.tsx @@ -18,6 +18,7 @@ import { ChartsPluginStart } from '../../../../../src/plugins/charts/public'; import { DataPublicPluginStart } from '../../../../../src/plugins/data/public'; import { PluginStartContract as AlertingStart } from '../../../alerting/public'; import type { SpacesPluginStart } from '../../../spaces/public'; +import type { SpacesOssPluginStart } from '../../../../../src/plugins/spaces_oss/public'; import { suspendedComponentWithProps } from './lib/suspended_component_with_props'; import { Storage } from '../../../../../src/plugins/kibana_utils/public'; @@ -36,6 +37,7 @@ export interface TriggersAndActionsUiServices extends CoreStart { charts: ChartsPluginStart; alerting?: AlertingStart; spaces?: SpacesPluginStart; + spacesOss: SpacesOssPluginStart; storage?: Storage; setBreadcrumbs: (crumbs: ChromeBreadcrumb[]) => void; actionTypeRegistry: ActionTypeRegistryContract; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/common_transformations.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/common_transformations.ts index 749cf53cf740b..5049a37c317dd 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/common_transformations.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/common_transformations.ts @@ -6,7 +6,7 @@ */ import { AlertExecutionStatus } from '../../../../../alerting/common'; import { AsApiContract, RewriteRequestCase } from '../../../../../actions/common'; -import { Alert, AlertAction } from '../../../types'; +import { Alert, AlertAction, ResolvedRule } from '../../../types'; const transformAction: RewriteRequestCase = ({ group, @@ -59,3 +59,16 @@ export const transformAlert: RewriteRequestCase = ({ scheduledTaskId, ...rest, }); + +export const transformResolvedRule: RewriteRequestCase = ({ + // eslint-disable-next-line @typescript-eslint/naming-convention + alias_target_id, + outcome, + ...rest +}: any) => { + return { + ...transformAlert(rest), + alias_target_id, + outcome, + }; +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/index.ts index a0b090a474e28..c499f7955e2fe 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/index.ts @@ -22,3 +22,4 @@ export { loadAlertState } from './state'; export { unmuteAlertInstance } from './unmute_alert'; export { unmuteAlert, unmuteAlerts } from './unmute'; export { updateAlert } from './update'; +export { resolveRule } from './resolve_rule'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/resolve_rule.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/resolve_rule.test.ts new file mode 100644 index 0000000000000..14b64f56f31ff --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/resolve_rule.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 { httpServiceMock } from '../../../../../../../src/core/public/mocks'; +import { resolveRule } from './resolve_rule'; +import uuid from 'uuid'; + +const http = httpServiceMock.createStartContract(); + +describe('resolveRule', () => { + test('should call get API with base parameters', async () => { + const ruleId = `${uuid.v4()}/`; + const ruleIdEncoded = encodeURIComponent(ruleId); + const resolvedValue = { + id: '1/', + params: { + aggType: 'count', + termSize: 5, + thresholdComparator: '>', + timeWindowSize: 5, + timeWindowUnit: 'm', + groupBy: 'all', + threshold: [1000], + index: ['.kibana'], + timeField: 'canvas-element.@created', + }, + consumer: 'alerts', + schedule: { interval: '1m' }, + tags: ['sdfsdf'], + name: 'dfsdfdsf', + enabled: true, + throttle: '1h', + rule_type_id: '.index-threshold', + created_by: 'elastic', + updated_by: 'elastic', + created_at: '2021-04-01T20:29:18.652Z', + updated_at: '2021-04-01T20:33:38.260Z', + api_key_owner: 'elastic', + notify_when: 'onThrottleInterval', + mute_all: false, + muted_alert_ids: [], + scheduled_task_id: '1', + execution_status: { status: 'ok', last_execution_date: '2021-04-01T21:16:46.709Z' }, + actions: [ + { + group: 'threshold met', + id: '1', + params: { documents: [{ dsfsdf: 1212 }] }, + connector_type_id: '.index', + }, + ], + outcome: 'aliasMatch', + alias_target_id: '2', + }; + http.get.mockResolvedValueOnce(resolvedValue); + + expect(await resolveRule({ http, ruleId })).toEqual({ + id: '1/', + params: { + aggType: 'count', + termSize: 5, + thresholdComparator: '>', + timeWindowSize: 5, + timeWindowUnit: 'm', + groupBy: 'all', + threshold: [1000], + index: ['.kibana'], + timeField: 'canvas-element.@created', + }, + consumer: 'alerts', + schedule: { interval: '1m' }, + tags: ['sdfsdf'], + name: 'dfsdfdsf', + enabled: true, + throttle: '1h', + alertTypeId: '.index-threshold', + createdBy: 'elastic', + updatedBy: 'elastic', + createdAt: '2021-04-01T20:29:18.652Z', + updatedAt: '2021-04-01T20:33:38.260Z', + apiKeyOwner: 'elastic', + notifyWhen: 'onThrottleInterval', + muteAll: false, + mutedInstanceIds: [], + scheduledTaskId: '1', + executionStatus: { status: 'ok', lastExecutionDate: '2021-04-01T21:16:46.709Z' }, + actions: [ + { + group: 'threshold met', + id: '1', + params: { documents: [{ dsfsdf: 1212 }] }, + actionTypeId: '.index', + }, + ], + outcome: 'aliasMatch', + alias_target_id: '2', + }); + expect(http.get).toHaveBeenCalledWith(`/internal/alerting/rule/${ruleIdEncoded}/_resolve`); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/resolve_rule.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/resolve_rule.ts new file mode 100644 index 0000000000000..bc2a19d298f8a --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/resolve_rule.ts @@ -0,0 +1,23 @@ +/* + * 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 { HttpSetup } from 'kibana/public'; +import { ResolvedRule } from '../../../types'; +import { INTERNAL_BASE_ALERTING_API_PATH } from '../../constants'; +import { transformResolvedRule } from './common_transformations'; + +export async function resolveRule({ + http, + ruleId, +}: { + http: HttpSetup; + ruleId: string; +}): Promise { + const res = await http.get( + `${INTERNAL_BASE_ALERTING_API_PATH}/rule/${encodeURIComponent(ruleId)}/_resolve` + ); + return transformResolvedRule(res); +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details_route.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details_route.test.tsx index 41c70a6737fa0..441daad1a50bd 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details_route.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details_route.test.tsx @@ -8,45 +8,150 @@ import * as React from 'react'; import uuid from 'uuid'; import { shallow } from 'enzyme'; +import { mountWithIntl, nextTick } from '@kbn/test/jest'; +import { act } from 'react-dom/test-utils'; import { createMemoryHistory, createLocation } from 'history'; import { ToastsApi } from 'kibana/public'; -import { AlertDetailsRoute, getAlertData } from './alert_details_route'; +import { AlertDetailsRoute, getRuleData } from './alert_details_route'; import { Alert } from '../../../../types'; import { CenterJustifiedSpinner } from '../../../components/center_justified_spinner'; +import { spacesOssPluginMock } from 'src/plugins/spaces_oss/public/mocks'; +import { useKibana } from '../../../../common/lib/kibana'; jest.mock('../../../../common/lib/kibana'); +class NotFoundError extends Error { + public readonly body: { + statusCode: number; + name: string; + } = { + statusCode: 404, + name: 'Not found', + }; + + constructor(message: string | undefined) { + super(message); + } +} + describe('alert_details_route', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const spacesOssMock = spacesOssPluginMock.createStartContract(); + async function setup() { + const useKibanaMock = useKibana as jest.Mocked; + // eslint-disable-next-line react-hooks/rules-of-hooks + useKibanaMock().services.spacesOss = spacesOssMock; + } + it('render a loader while fetching data', () => { - const alert = mockAlert(); + const rule = mockRule(); expect( shallow( - + ).containsMatchingElement() ).toBeTruthy(); }); + + it('redirects to another page if fetched rule is an aliasMatch', async () => { + await setup(); + const rule = mockRule(); + const { loadAlert, resolveRule } = mockApis(); + + loadAlert.mockImplementationOnce(async () => { + throw new NotFoundError('OMG'); + }); + resolveRule.mockImplementationOnce(async () => ({ + ...rule, + id: 'new_id', + outcome: 'aliasMatch', + alias_target_id: rule.id, + })); + const wrapper = mountWithIntl( + + ); + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(loadAlert).toHaveBeenCalledWith(rule.id); + expect(resolveRule).toHaveBeenCalledWith(rule.id); + expect((spacesOssMock as any).ui.redirectLegacyUrl).toHaveBeenCalledWith( + `insightsAndAlerting/triggersActions/rule/new_id`, + `rule` + ); + }); + + it('shows warning callout if fetched rule is a conflict', async () => { + await setup(); + const rule = mockRule(); + const ruleType = { + id: rule.alertTypeId, + name: 'type name', + authorizedConsumers: ['consumer'], + }; + const { loadAlert, loadAlertTypes, loadActionTypes, resolveRule } = mockApis(); + + loadAlert.mockImplementationOnce(async () => { + throw new NotFoundError('OMG'); + }); + loadAlertTypes.mockImplementationOnce(async () => [ruleType]); + loadActionTypes.mockImplementation(async () => []); + resolveRule.mockImplementationOnce(async () => ({ + ...rule, + id: 'new_id', + outcome: 'conflict', + alias_target_id: rule.id, + })); + const wrapper = mountWithIntl( + + ); + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(loadAlert).toHaveBeenCalledWith(rule.id); + expect(resolveRule).toHaveBeenCalledWith(rule.id); + expect((spacesOssMock as any).ui.components.getLegacyUrlConflict).toHaveBeenCalledWith({ + currentObjectId: 'new_id', + objectNoun: 'rule', + otherObjectId: rule.id, + otherObjectPath: `insightsAndAlerting/triggersActions/rule/${rule.id}`, + }); + }); }); -describe('getAlertData useEffect handler', () => { +describe('getRuleData useEffect handler', () => { beforeEach(() => { jest.clearAllMocks(); }); - it('fetches alert', async () => { - const alert = mockAlert(); - const { loadAlert, loadAlertTypes, loadActionTypes } = mockApis(); + it('fetches rule', async () => { + const rule = mockRule(); + const { loadAlert, loadAlertTypes, loadActionTypes, resolveRule } = mockApis(); const { setAlert, setAlertType, setActionTypes } = mockStateSetter(); - loadAlert.mockImplementationOnce(async () => alert); + loadAlert.mockImplementationOnce(async () => rule); const toastNotifications = ({ addDanger: jest.fn(), } as unknown) as ToastsApi; - await getAlertData( - alert.id, + await getRuleData( + rule.id, loadAlert, loadAlertTypes, + resolveRule, loadActionTypes, setAlert, setAlertType, @@ -54,45 +159,47 @@ describe('getAlertData useEffect handler', () => { toastNotifications ); - expect(loadAlert).toHaveBeenCalledWith(alert.id); - expect(setAlert).toHaveBeenCalledWith(alert); + expect(loadAlert).toHaveBeenCalledWith(rule.id); + expect(resolveRule).not.toHaveBeenCalled(); + expect(setAlert).toHaveBeenCalledWith(rule); }); - it('fetches alert and action types', async () => { - const actionType = { + it('fetches rule and connector types', async () => { + const connectorType = { id: '.server-log', name: 'Server log', enabled: true, }; - const alert = mockAlert({ + const rule = mockRule({ actions: [ { group: '', id: uuid.v4(), - actionTypeId: actionType.id, + actionTypeId: connectorType.id, params: {}, }, ], }); - const alertType = { - id: alert.alertTypeId, + const ruleType = { + id: rule.alertTypeId, name: 'type name', }; - const { loadAlert, loadAlertTypes, loadActionTypes } = mockApis(); + const { loadAlert, loadAlertTypes, loadActionTypes, resolveRule } = mockApis(); const { setAlert, setAlertType, setActionTypes } = mockStateSetter(); - loadAlert.mockImplementation(async () => alert); - loadAlertTypes.mockImplementation(async () => [alertType]); - loadActionTypes.mockImplementation(async () => [actionType]); + loadAlert.mockImplementation(async () => rule); + loadAlertTypes.mockImplementation(async () => [ruleType]); + loadActionTypes.mockImplementation(async () => [connectorType]); const toastNotifications = ({ addDanger: jest.fn(), } as unknown) as ToastsApi; - await getAlertData( - alert.id, + await getRuleData( + rule.id, loadAlert, loadAlertTypes, + resolveRule, loadActionTypes, setAlert, setAlertType, @@ -102,29 +209,76 @@ describe('getAlertData useEffect handler', () => { expect(loadAlertTypes).toHaveBeenCalledTimes(1); expect(loadActionTypes).toHaveBeenCalledTimes(1); + expect(resolveRule).not.toHaveBeenCalled(); + + expect(setAlert).toHaveBeenCalledWith(rule); + expect(setAlertType).toHaveBeenCalledWith(ruleType); + expect(setActionTypes).toHaveBeenCalledWith([connectorType]); + }); + + it('fetches rule using resolve if initial GET results in a 404 error', async () => { + const connectorType = { + id: '.server-log', + name: 'Server log', + enabled: true, + }; + const rule = mockRule({ + actions: [ + { + group: '', + id: uuid.v4(), + actionTypeId: connectorType.id, + params: {}, + }, + ], + }); + + const { loadAlert, loadAlertTypes, loadActionTypes, resolveRule } = mockApis(); + const { setAlert, setAlertType, setActionTypes } = mockStateSetter(); + + loadAlert.mockImplementationOnce(async () => { + throw new NotFoundError('OMG'); + }); + resolveRule.mockImplementationOnce(async () => rule); + + const toastNotifications = ({ + addDanger: jest.fn(), + } as unknown) as ToastsApi; + await getRuleData( + rule.id, + loadAlert, + loadAlertTypes, + resolveRule, + loadActionTypes, + setAlert, + setAlertType, + setActionTypes, + toastNotifications + ); - expect(setAlertType).toHaveBeenCalledWith(alertType); - expect(setActionTypes).toHaveBeenCalledWith([actionType]); + expect(loadAlert).toHaveBeenCalledWith(rule.id); + expect(resolveRule).toHaveBeenCalledWith(rule.id); + expect(setAlert).toHaveBeenCalledWith(rule); }); - it('displays an error if the alert isnt found', async () => { - const actionType = { + it('displays an error if fetching the rule results in a non-404 error', async () => { + const connectorType = { id: '.server-log', name: 'Server log', enabled: true, }; - const alert = mockAlert({ + const rule = mockRule({ actions: [ { group: '', id: uuid.v4(), - actionTypeId: actionType.id, + actionTypeId: connectorType.id, params: {}, }, ], }); - const { loadAlert, loadAlertTypes, loadActionTypes } = mockApis(); + const { loadAlert, loadAlertTypes, loadActionTypes, resolveRule } = mockApis(); const { setAlert, setAlertType, setActionTypes } = mockStateSetter(); loadAlert.mockImplementation(async () => { @@ -134,10 +288,11 @@ describe('getAlertData useEffect handler', () => { const toastNotifications = ({ addDanger: jest.fn(), } as unknown) as ToastsApi; - await getAlertData( - alert.id, + await getRuleData( + rule.id, loadAlert, loadAlertTypes, + resolveRule, loadActionTypes, setAlert, setAlertType, @@ -150,40 +305,41 @@ describe('getAlertData useEffect handler', () => { }); }); - it('displays an error if the alert type isnt loaded', async () => { - const actionType = { + it('displays an error if the rule type isnt loaded', async () => { + const connectorType = { id: '.server-log', name: 'Server log', enabled: true, }; - const alert = mockAlert({ + const rule = mockRule({ actions: [ { group: '', id: uuid.v4(), - actionTypeId: actionType.id, + actionTypeId: connectorType.id, params: {}, }, ], }); - const { loadAlert, loadAlertTypes, loadActionTypes } = mockApis(); + const { loadAlert, loadAlertTypes, loadActionTypes, resolveRule } = mockApis(); const { setAlert, setAlertType, setActionTypes } = mockStateSetter(); - loadAlert.mockImplementation(async () => alert); + loadAlert.mockImplementation(async () => rule); loadAlertTypes.mockImplementation(async () => { - throw new Error('OMG no alert type'); + throw new Error('OMG no rule type'); }); - loadActionTypes.mockImplementation(async () => [actionType]); + loadActionTypes.mockImplementation(async () => [connectorType]); const toastNotifications = ({ addDanger: jest.fn(), } as unknown) as ToastsApi; - await getAlertData( - alert.id, + await getRuleData( + rule.id, loadAlert, loadAlertTypes, + resolveRule, loadActionTypes, setAlert, setAlertType, @@ -192,48 +348,49 @@ describe('getAlertData useEffect handler', () => { ); expect(toastNotifications.addDanger).toHaveBeenCalledTimes(1); expect(toastNotifications.addDanger).toHaveBeenCalledWith({ - title: 'Unable to load rule: OMG no alert type', + title: 'Unable to load rule: OMG no rule type', }); }); - it('displays an error if the action type isnt loaded', async () => { - const actionType = { + it('displays an error if the connector type isnt loaded', async () => { + const connectorType = { id: '.server-log', name: 'Server log', enabled: true, }; - const alert = mockAlert({ + const rule = mockRule({ actions: [ { group: '', id: uuid.v4(), - actionTypeId: actionType.id, + actionTypeId: connectorType.id, params: {}, }, ], }); - const alertType = { - id: alert.alertTypeId, + const ruleType = { + id: rule.alertTypeId, name: 'type name', }; - const { loadAlert, loadAlertTypes, loadActionTypes } = mockApis(); + const { loadAlert, loadAlertTypes, loadActionTypes, resolveRule } = mockApis(); const { setAlert, setAlertType, setActionTypes } = mockStateSetter(); - loadAlert.mockImplementation(async () => alert); + loadAlert.mockImplementation(async () => rule); - loadAlertTypes.mockImplementation(async () => [alertType]); + loadAlertTypes.mockImplementation(async () => [ruleType]); loadActionTypes.mockImplementation(async () => { - throw new Error('OMG no action type'); + throw new Error('OMG no connector type'); }); const toastNotifications = ({ addDanger: jest.fn(), } as unknown) as ToastsApi; - await getAlertData( - alert.id, + await getRuleData( + rule.id, loadAlert, loadAlertTypes, + resolveRule, loadActionTypes, setAlert, setAlertType, @@ -242,46 +399,47 @@ describe('getAlertData useEffect handler', () => { ); expect(toastNotifications.addDanger).toHaveBeenCalledTimes(1); expect(toastNotifications.addDanger).toHaveBeenCalledWith({ - title: 'Unable to load rule: OMG no action type', + title: 'Unable to load rule: OMG no connector type', }); }); - it('displays an error if the alert type isnt found', async () => { - const actionType = { + it('displays an error if the rule type isnt found', async () => { + const connectorType = { id: '.server-log', name: 'Server log', enabled: true, }; - const alert = mockAlert({ + const rule = mockRule({ actions: [ { group: '', id: uuid.v4(), - actionTypeId: actionType.id, + actionTypeId: connectorType.id, params: {}, }, ], }); - const alertType = { + const ruleType = { id: uuid.v4(), name: 'type name', }; - const { loadAlert, loadAlertTypes, loadActionTypes } = mockApis(); + const { loadAlert, loadAlertTypes, loadActionTypes, resolveRule } = mockApis(); const { setAlert, setAlertType, setActionTypes } = mockStateSetter(); - loadAlert.mockImplementation(async () => alert); - loadAlertTypes.mockImplementation(async () => [alertType]); - loadActionTypes.mockImplementation(async () => [actionType]); + loadAlert.mockImplementation(async () => rule); + loadAlertTypes.mockImplementation(async () => [ruleType]); + loadActionTypes.mockImplementation(async () => [connectorType]); const toastNotifications = ({ addDanger: jest.fn(), } as unknown) as ToastsApi; - await getAlertData( - alert.id, + await getRuleData( + rule.id, loadAlert, loadAlertTypes, + resolveRule, loadActionTypes, setAlert, setAlertType, @@ -290,57 +448,58 @@ describe('getAlertData useEffect handler', () => { ); expect(toastNotifications.addDanger).toHaveBeenCalledTimes(1); expect(toastNotifications.addDanger).toHaveBeenCalledWith({ - title: `Unable to load rule: Invalid Alert Type: ${alert.alertTypeId}`, + title: `Unable to load rule: Invalid Rule Type: ${rule.alertTypeId}`, }); }); it('displays an error if an action type isnt found', async () => { - const availableActionType = { + const availableConnectorType = { id: '.server-log', name: 'Server log', enabled: true, }; - const missingActionType = { + const missingConnectorType = { id: '.noop', name: 'No Op', enabled: true, }; - const alert = mockAlert({ + const rule = mockRule({ actions: [ { group: '', id: uuid.v4(), - actionTypeId: availableActionType.id, + actionTypeId: availableConnectorType.id, params: {}, }, { group: '', id: uuid.v4(), - actionTypeId: missingActionType.id, + actionTypeId: missingConnectorType.id, params: {}, }, ], }); - const alertType = { + const ruleType = { id: uuid.v4(), name: 'type name', }; - const { loadAlert, loadAlertTypes, loadActionTypes } = mockApis(); + const { loadAlert, loadAlertTypes, loadActionTypes, resolveRule } = mockApis(); const { setAlert, setAlertType, setActionTypes } = mockStateSetter(); - loadAlert.mockImplementation(async () => alert); - loadAlertTypes.mockImplementation(async () => [alertType]); - loadActionTypes.mockImplementation(async () => [availableActionType]); + loadAlert.mockImplementation(async () => rule); + loadAlertTypes.mockImplementation(async () => [ruleType]); + loadActionTypes.mockImplementation(async () => [availableConnectorType]); const toastNotifications = ({ addDanger: jest.fn(), } as unknown) as ToastsApi; - await getAlertData( - alert.id, + await getRuleData( + rule.id, loadAlert, loadAlertTypes, + resolveRule, loadActionTypes, setAlert, setAlertType, @@ -349,7 +508,7 @@ describe('getAlertData useEffect handler', () => { ); expect(toastNotifications.addDanger).toHaveBeenCalledTimes(1); expect(toastNotifications.addDanger).toHaveBeenCalledWith({ - title: `Unable to load rule: Invalid Action Type: ${missingActionType.id}`, + title: `Unable to load rule: Invalid Connector Type: ${missingConnectorType.id}`, }); }); }); @@ -359,6 +518,7 @@ function mockApis() { loadAlert: jest.fn(), loadAlertTypes: jest.fn(), loadActionTypes: jest.fn(), + resolveRule: jest.fn(), }; } @@ -370,23 +530,23 @@ function mockStateSetter() { }; } -function mockRouterProps(alert: Alert) { +function mockRouterProps(rule: Alert) { return { match: { isExact: false, - path: `/rule/${alert.id}`, + path: `/rule/${rule.id}`, url: '', - params: { ruleId: alert.id }, + params: { ruleId: rule.id }, }, history: createMemoryHistory(), - location: createLocation(`/rule/${alert.id}`), + location: createLocation(`/rule/${rule.id}`), }; } -function mockAlert(overloads: Partial = {}): Alert { +function mockRule(overloads: Partial = {}): Alert { return { id: uuid.v4(), enabled: true, - name: `alert-${uuid.v4()}`, + name: `rule-${uuid.v4()}`, tags: [], alertTypeId: '.noop', consumer: 'consumer', diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details_route.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details_route.tsx index 2d6db5f6330cc..b6279d7fca100 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details_route.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details_route.tsx @@ -9,7 +9,8 @@ import { i18n } from '@kbn/i18n'; import React, { useState, useEffect } from 'react'; import { RouteComponentProps } from 'react-router-dom'; import { ToastsApi } from 'kibana/public'; -import { Alert, AlertType, ActionType } from '../../../../types'; +import { EuiSpacer } from '@elastic/eui'; +import { Alert, AlertType, ActionType, ResolvedRule } from '../../../../types'; import { AlertDetailsWithApi as AlertDetails } from './alert_details'; import { throwIfAbsent, throwIfIsntContained } from '../../../lib/value_validators'; import { @@ -27,7 +28,7 @@ type AlertDetailsRouteProps = RouteComponentProps<{ ruleId: string; }> & Pick & - Pick; + Pick; export const AlertDetailsRoute: React.FunctionComponent = ({ match: { @@ -36,63 +37,127 @@ export const AlertDetailsRoute: React.FunctionComponent loadAlert, loadAlertTypes, loadActionTypes, + resolveRule, }) => { const { http, notifications: { toasts }, + spacesOss, } = useKibana().services; - const [alert, setAlert] = useState(null); + const { basePath } = http; + + const [alert, setAlert] = useState(null); const [alertType, setAlertType] = useState(null); const [actionTypes, setActionTypes] = useState(null); const [refreshToken, requestRefresh] = React.useState(); useEffect(() => { - getAlertData( + getRuleData( ruleId, loadAlert, loadAlertTypes, + resolveRule, loadActionTypes, setAlert, setAlertType, setActionTypes, toasts ); - }, [ruleId, http, loadActionTypes, loadAlert, loadAlertTypes, toasts, refreshToken]); + }, [ruleId, http, loadActionTypes, loadAlert, loadAlertTypes, resolveRule, toasts, refreshToken]); + + useEffect(() => { + if (alert) { + const outcome = (alert as ResolvedRule).outcome; + if (outcome === 'aliasMatch' && spacesOss.isSpacesAvailable) { + // This rule has been resolved from a legacy URL - redirect the user to the new URL and display a toast. + const path = basePath.prepend(`insightsAndAlerting/triggersActions/rule/${alert.id}`); + spacesOss.ui.redirectLegacyUrl( + path, + i18n.translate('xpack.triggersActionsUI.sections.alertDetails.redirectObjectNoun', { + defaultMessage: 'rule', + }) + ); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [alert]); + + const getLegacyUrlConflictCallout = () => { + const outcome = (alert as ResolvedRule).outcome; + const aliasTargetId = (alert as ResolvedRule).alias_target_id; + if (outcome === 'conflict' && aliasTargetId && spacesOss.isSpacesAvailable) { + // We have resolved to one rule, but there is another one with a legacy URL associated with this page. Display a + // callout with a warning for the user, and provide a way for them to navigate to the other rule. + const otherRulePath = basePath.prepend( + `insightsAndAlerting/triggersActions/rule/${aliasTargetId}` + ); + return ( + <> + + {spacesOss.ui.components.getLegacyUrlConflict({ + objectNoun: i18n.translate( + 'xpack.triggersActionsUI.sections.alertDetails.redirectObjectNoun', + { + defaultMessage: 'rule', + } + ), + currentObjectId: alert?.id!, + otherObjectId: aliasTargetId, + otherObjectPath: otherRulePath, + })} + + ); + } + return null; + }; return alert && alertType && actionTypes ? ( - requestRefresh(Date.now())} - /> + <> + {getLegacyUrlConflictCallout()} + requestRefresh(Date.now())} + /> + ) : ( ); }; -export async function getAlertData( - alertId: string, +export async function getRuleData( + ruleId: string, loadAlert: AlertApis['loadAlert'], loadAlertTypes: AlertApis['loadAlertTypes'], + resolveRule: AlertApis['resolveRule'], loadActionTypes: ActionApis['loadActionTypes'], - setAlert: React.Dispatch>, + setAlert: React.Dispatch>, setAlertType: React.Dispatch>, setActionTypes: React.Dispatch>, toasts: Pick ) { try { - const loadedAlert = await loadAlert(alertId); - setAlert(loadedAlert); + let loadedRule: Alert | ResolvedRule; + try { + loadedRule = await loadAlert(ruleId); + } catch (err) { + // Try resolving this rule id if the error is a 404, otherwise re-throw + if (err?.body?.statusCode !== 404) { + throw err; + } + loadedRule = await resolveRule(ruleId); + } + setAlert(loadedRule); const [loadedAlertType, loadedActionTypes] = await Promise.all([ loadAlertTypes() - .then((types) => types.find((type) => type.id === loadedAlert.alertTypeId)) - .then(throwIfAbsent(`Invalid Alert Type: ${loadedAlert.alertTypeId}`)), + .then((types) => types.find((type) => type.id === loadedRule.alertTypeId)) + .then(throwIfAbsent(`Invalid Rule Type: ${loadedRule.alertTypeId}`)), loadActionTypes().then( throwIfIsntContained( - new Set(loadedAlert.actions.map((action) => action.actionTypeId)), - (requiredActionType: string) => `Invalid Action Type: ${requiredActionType}`, + new Set(loadedRule.actions.map((action) => action.actionTypeId)), + (requiredActionType: string) => `Invalid Connector Type: ${requiredActionType}`, (action: ActionType) => action.id ) ), diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.test.tsx index 7d314cef55680..806f649e7d033 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.test.tsx @@ -36,6 +36,7 @@ describe('with_bulk_alert_api_operations', () => { expect(typeof props.deleteAlert).toEqual('function'); expect(typeof props.loadAlert).toEqual('function'); expect(typeof props.loadAlertTypes).toEqual('function'); + expect(typeof props.resolveRule).toEqual('function'); return
; }; @@ -220,6 +221,24 @@ describe('with_bulk_alert_api_operations', () => { expect(alertApi.loadAlert).toHaveBeenCalledWith({ alertId, http }); }); + it('resolveRule calls the resolveRule api', () => { + const { http } = useKibanaMock().services; + const ComponentToExtend = ({ + resolveRule, + ruleId, + }: ComponentOpts & { ruleId: Alert['id'] }) => { + return ; + }; + + const ExtendedComponent = withBulkAlertOperations(ComponentToExtend); + const ruleId = uuid.v4(); + const component = mount(); + component.find('button').simulate('click'); + + expect(alertApi.resolveRule).toHaveBeenCalledTimes(1); + expect(alertApi.resolveRule).toHaveBeenCalledWith({ ruleId, http }); + }); + it('loadAlertTypes calls the loadAlertTypes api', () => { const { http } = useKibanaMock().services; const ComponentToExtend = ({ loadAlertTypes }: ComponentOpts) => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.tsx index 983fe5641e62b..59919c202277c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.tsx @@ -13,6 +13,7 @@ import { AlertTaskState, AlertInstanceSummary, AlertingFrameworkHealth, + ResolvedRule, } from '../../../../types'; import { deleteAlerts, @@ -31,6 +32,7 @@ import { loadAlertInstanceSummary, loadAlertTypes, alertingFrameworkHealth, + resolveRule, } from '../../../lib/alert_api'; import { useKibana } from '../../../../common/lib/kibana'; @@ -62,6 +64,7 @@ export interface ComponentOpts { loadAlertInstanceSummary: (id: Alert['id']) => Promise; loadAlertTypes: () => Promise; getHealth: () => Promise; + resolveRule: (id: Alert['id']) => Promise; } export type PropsWithOptionalApiHandlers = Omit & Partial; @@ -132,6 +135,7 @@ export function withBulkAlertOperations( loadAlertInstanceSummary({ http, alertId }) } loadAlertTypes={async () => loadAlertTypes({ http })} + resolveRule={async (ruleId: Alert['id']) => resolveRule({ http, ruleId })} getHealth={async () => alertingFrameworkHealth({ http })} /> ); diff --git a/x-pack/plugins/triggers_actions_ui/public/common/lib/kibana/kibana_react.mock.ts b/x-pack/plugins/triggers_actions_ui/public/common/lib/kibana/kibana_react.mock.ts index a1a0184198dfd..f8aa483711c30 100644 --- a/x-pack/plugins/triggers_actions_ui/public/common/lib/kibana/kibana_react.mock.ts +++ b/x-pack/plugins/triggers_actions_ui/public/common/lib/kibana/kibana_react.mock.ts @@ -8,7 +8,7 @@ import React from 'react'; import { chartPluginMock } from '../../../../../../../src/plugins/charts/public/mocks'; import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks'; - +import { spacesOssPluginMock } from '../../../../../../../src/plugins/spaces_oss/public/mocks'; import { coreMock, scopedHistoryMock } from '../../../../../../../src/core/public/mocks'; import { KibanaContextProvider } from '../../../../../../../src/plugins/kibana_react/public'; import { TriggersAndActionsUiServices } from '../../../application/app'; @@ -45,6 +45,7 @@ export const createStartServicesMock = (): TriggersAndActionsUiServices => { element: ({ style: { cursor: 'pointer' }, } as unknown) as HTMLElement, + spacesOss: spacesOssPluginMock.createStartContract(), } as TriggersAndActionsUiServices; }; diff --git a/x-pack/plugins/triggers_actions_ui/public/plugin.ts b/x-pack/plugins/triggers_actions_ui/public/plugin.ts index 7661eefba7f65..36d6964ce7753 100644 --- a/x-pack/plugins/triggers_actions_ui/public/plugin.ts +++ b/x-pack/plugins/triggers_actions_ui/public/plugin.ts @@ -26,6 +26,7 @@ import { PluginStartContract as AlertingStart } from '../../alerting/public'; import { DataPublicPluginStart } from '../../../../src/plugins/data/public'; import { Storage } from '../../../../src/plugins/kibana_utils/public'; import type { SpacesPluginStart } from '../../spaces/public'; +import type { SpacesOssPluginStart } from '../../../../src/plugins/spaces_oss/public'; import { getAddConnectorFlyoutLazy } from './common/get_add_connector_flyout'; import { getEditConnectorFlyoutLazy } from './common/get_edit_connector_flyout'; @@ -73,6 +74,7 @@ interface PluginsStart { charts: ChartsPluginStart; alerting?: AlertingStart; spaces?: SpacesPluginStart; + spacesOss: SpacesOssPluginStart; navigateToApp: CoreStart['application']['navigateToApp']; features: FeaturesPluginStart; } @@ -148,6 +150,7 @@ export class Plugin charts: pluginsStart.charts, alerting: pluginsStart.alerting, spaces: pluginsStart.spaces, + spacesOss: pluginsStart.spacesOss, element: params.element, storage: new Storage(window.localStorage), setBreadcrumbs: params.setBreadcrumbs, diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index f01967592ea8c..ae4fd5152794f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -24,6 +24,7 @@ import { ActionGroup, AlertActionParam, SanitizedAlert, + ResolvedSanitizedRule, AlertAction, AlertAggregations, AlertTaskState, @@ -40,6 +41,7 @@ import { // In Triggers and Actions we treat all `Alert`s as `SanitizedAlert` // so the `Params` is a black-box of Record type Alert = SanitizedAlert; +type ResolvedRule = ResolvedSanitizedRule; export { Alert, @@ -52,6 +54,7 @@ export { AlertingFrameworkHealth, AlertNotifyWhenType, AlertTypeParams, + ResolvedRule, }; export { ActionType, diff --git a/x-pack/plugins/triggers_actions_ui/tsconfig.json b/x-pack/plugins/triggers_actions_ui/tsconfig.json index 6536206acf369..e3c8b77d2c1d4 100644 --- a/x-pack/plugins/triggers_actions_ui/tsconfig.json +++ b/x-pack/plugins/triggers_actions_ui/tsconfig.json @@ -24,5 +24,6 @@ { "path": "../../../src/plugins/kibana_react/tsconfig.json" }, { "path": "../../../src/plugins/kibana_utils/tsconfig.json" }, { "path": "../../../src/plugins/management/tsconfig.json" }, + { "path": "../../../src/plugins/spaces_oss/tsconfig.json" }, ] }