[Actions] avoids setting a default dedupKey on PagerDuty (#77773)

The PagerDuty Action currently defaults to a dedupKey that's shared between all action executions of the same connector.
To ensure we don't group unrelated executions together this PR avoids setting a default, which means each execution will result in its own incident in PD.

As part of this change we've also made the `dedupKey` a required field whenever a `resolve` or `acknowledge` event_action is chosen. This ensure we don't try to resolve without a dedupKey, which would result in an error in PD.

A migration has been introduced to migrate existing alerts which might not have a `dedupKey` configured.
This commit is contained in:
Gidi Meir Morris 2020-09-28 14:56:20 +01:00 committed by GitHub
parent 8841757874
commit 8547b32bab
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 471 additions and 67 deletions

View file

@ -169,6 +169,16 @@ describe('validateParams()', () => {
});
}).toThrowError(`error validating action params: error parsing timestamp "${timestamp}"`);
});
test('should validate and throw error when dedupKey is missing on resolve', () => {
expect(() => {
validateParams(actionType, {
eventAction: 'resolve',
});
}).toThrowError(
`error validating action params: DedupKey is required when eventAction is "resolve"`
);
});
});
describe('execute()', () => {
@ -199,7 +209,6 @@ describe('execute()', () => {
Object {
"apiUrl": "https://events.pagerduty.com/v2/enqueue",
"data": Object {
"dedup_key": "action:some-action-id",
"event_action": "trigger",
"payload": Object {
"severity": "info",
@ -509,4 +518,61 @@ describe('execute()', () => {
}
`);
});
test('should not set a default dedupkey to ensure each execution is a unique PagerDuty incident', async () => {
const randoDate = new Date('1963-09-23T01:23:45Z').toISOString();
const secrets = {
routingKey: 'super-secret',
};
const config = {
apiUrl: 'the-api-url',
};
const params: ActionParamsType = {
eventAction: 'trigger',
summary: 'the summary',
source: 'the-source',
severity: 'critical',
timestamp: randoDate,
};
postPagerdutyMock.mockImplementation(() => {
return { status: 202, data: 'data-here' };
});
const actionId = 'some-action-id';
const executorOptions: PagerDutyActionTypeExecutorOptions = {
actionId,
config,
params,
secrets,
services,
};
const actionResponse = await actionType.executor(executorOptions);
const { apiUrl, data, headers } = postPagerdutyMock.mock.calls[0][0];
expect({ apiUrl, data, headers }).toMatchInlineSnapshot(`
Object {
"apiUrl": "the-api-url",
"data": Object {
"event_action": "trigger",
"payload": Object {
"severity": "critical",
"source": "the-source",
"summary": "the summary",
"timestamp": "1963-09-23T01:23:45.000Z",
},
},
"headers": Object {
"Content-Type": "application/json",
"X-Routing-Key": "super-secret",
},
}
`);
expect(actionResponse).toMatchInlineSnapshot(`
Object {
"actionId": "some-action-id",
"data": "data-here",
"status": "ok",
}
`);
});
});

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { curry } from 'lodash';
import { curry, isUndefined, pick, omitBy } from 'lodash';
import { i18n } from '@kbn/i18n';
import { schema, TypeOf } from '@kbn/config-schema';
import { postPagerduty } from './lib/post_pagerduty';
@ -51,6 +51,10 @@ export type ActionParamsType = TypeOf<typeof ParamsSchema>;
const EVENT_ACTION_TRIGGER = 'trigger';
const EVENT_ACTION_RESOLVE = 'resolve';
const EVENT_ACTION_ACKNOWLEDGE = 'acknowledge';
const EVENT_ACTIONS_WITH_REQUIRED_DEDUPKEY = new Set([
EVENT_ACTION_RESOLVE,
EVENT_ACTION_ACKNOWLEDGE,
]);
const EventActionSchema = schema.oneOf([
schema.literal(EVENT_ACTION_TRIGGER),
@ -81,7 +85,7 @@ const ParamsSchema = schema.object(
);
function validateParams(paramsObject: unknown): string | void {
const { timestamp } = paramsObject as ActionParamsType;
const { timestamp, eventAction, dedupKey } = paramsObject as ActionParamsType;
if (timestamp != null) {
try {
const date = Date.parse(timestamp);
@ -103,6 +107,14 @@ function validateParams(paramsObject: unknown): string | void {
});
}
}
if (eventAction && EVENT_ACTIONS_WITH_REQUIRED_DEDUPKEY.has(eventAction) && !dedupKey) {
return i18n.translate('xpack.actions.builtin.pagerduty.missingDedupkeyErrorMessage', {
defaultMessage: `DedupKey is required when eventAction is "{eventAction}"`,
values: {
eventAction,
},
});
}
}
// action type definition
@ -230,26 +242,29 @@ async function executor(
const AcknowledgeOrResolve = new Set([EVENT_ACTION_ACKNOWLEDGE, EVENT_ACTION_RESOLVE]);
function getBodyForEventAction(actionId: string, params: ActionParamsType): unknown {
const eventAction = params.eventAction || EVENT_ACTION_TRIGGER;
const dedupKey = params.dedupKey || `action:${actionId}`;
const data: {
event_action: ActionParamsType['eventAction'];
dedup_key: string;
payload?: {
summary: string;
source: string;
severity: string;
timestamp?: string;
component?: string;
group?: string;
class?: string;
};
} = {
event_action: eventAction,
dedup_key: dedupKey,
interface PagerDutyPayload {
event_action: ActionParamsType['eventAction'];
dedup_key?: string;
payload?: {
summary: string;
source: string;
severity: string;
timestamp?: string;
component?: string;
group?: string;
class?: string;
};
}
function getBodyForEventAction(actionId: string, params: ActionParamsType): PagerDutyPayload {
const eventAction = params.eventAction ?? EVENT_ACTION_TRIGGER;
const data: PagerDutyPayload = {
event_action: eventAction,
};
if (params.dedupKey) {
data.dedup_key = params.dedupKey;
}
// for acknowledge / resolve, just send the dedup key
if (AcknowledgeOrResolve.has(eventAction)) {
@ -260,12 +275,8 @@ function getBodyForEventAction(actionId: string, params: ActionParamsType): unkn
summary: params.summary || 'No summary provided.',
source: params.source || `Kibana Action ${actionId}`,
severity: params.severity || 'info',
...omitBy(pick(params, ['timestamp', 'component', 'group', 'class']), isUndefined),
};
if (params.timestamp != null) data.payload.timestamp = params.timestamp;
if (params.component != null) data.payload.component = params.component;
if (params.group != null) data.payload.group = params.group;
if (params.class != null) data.payload.class = params.class;
return data;
}

View file

@ -85,6 +85,120 @@ describe('7.10.0', () => {
},
});
});
test('migrates PagerDuty actions to set a default dedupkey of the AlertId', () => {
const migration710 = getMigrations(encryptedSavedObjectsSetup)['7.10.0'];
const alert = getMockData({
actions: [
{
actionTypeId: '.pagerduty',
group: 'default',
params: {
summary: 'fired {{alertInstanceId}}',
eventAction: 'resolve',
component: '',
},
id: 'b62ea790-5366-4abc-a7df-33db1db78410',
},
],
});
expect(migration710(alert, { log })).toMatchObject({
...alert,
attributes: {
...alert.attributes,
actions: [
{
actionTypeId: '.pagerduty',
group: 'default',
params: {
summary: 'fired {{alertInstanceId}}',
eventAction: 'resolve',
dedupKey: '{{alertId}}',
component: '',
},
id: 'b62ea790-5366-4abc-a7df-33db1db78410',
},
],
},
});
});
test('skips PagerDuty actions with a specified dedupkey', () => {
const migration710 = getMigrations(encryptedSavedObjectsSetup)['7.10.0'];
const alert = getMockData({
actions: [
{
actionTypeId: '.pagerduty',
group: 'default',
params: {
summary: 'fired {{alertInstanceId}}',
eventAction: 'trigger',
dedupKey: '{{alertInstanceId}}',
component: '',
},
id: 'b62ea790-5366-4abc-a7df-33db1db78410',
},
],
});
expect(migration710(alert, { log })).toMatchObject({
...alert,
attributes: {
...alert.attributes,
actions: [
{
actionTypeId: '.pagerduty',
group: 'default',
params: {
summary: 'fired {{alertInstanceId}}',
eventAction: 'trigger',
dedupKey: '{{alertInstanceId}}',
component: '',
},
id: 'b62ea790-5366-4abc-a7df-33db1db78410',
},
],
},
});
});
test('skips PagerDuty actions with an eventAction of "trigger"', () => {
const migration710 = getMigrations(encryptedSavedObjectsSetup)['7.10.0'];
const alert = getMockData({
actions: [
{
actionTypeId: '.pagerduty',
group: 'default',
params: {
summary: 'fired {{alertInstanceId}}',
eventAction: 'trigger',
component: '',
},
id: 'b62ea790-5366-4abc-a7df-33db1db78410',
},
],
});
expect(migration710(alert, { log })).toEqual({
...alert,
attributes: {
...alert.attributes,
meta: {
versionApiKeyLastmodified: 'pre-7.10.0',
},
actions: [
{
actionTypeId: '.pagerduty',
group: 'default',
params: {
summary: 'fired {{alertInstanceId}}',
eventAction: 'trigger',
component: '',
},
id: 'b62ea790-5366-4abc-a7df-33db1db78410',
},
],
},
});
});
});
describe('7.10.0 migrates with failure', () => {

View file

@ -18,10 +18,20 @@ import {
export const LEGACY_LAST_MODIFIED_VERSION = 'pre-7.10.0';
type AlertMigration = (
doc: SavedObjectUnsanitizedDoc<RawAlert>
) => SavedObjectUnsanitizedDoc<RawAlert>;
export function getMigrations(
encryptedSavedObjects: EncryptedSavedObjectsPluginSetup
): SavedObjectMigrationMap {
const migrationWhenRBACWasIntroduced = markAsLegacyAndChangeConsumer(encryptedSavedObjects);
const migrationWhenRBACWasIntroduced = encryptedSavedObjects.createMigration<RawAlert, RawAlert>(
function shouldBeMigrated(doc): doc is SavedObjectUnsanitizedDoc<RawAlert> {
// migrate all documents in 7.10 in order to add the "meta" RBAC field
return true;
},
pipeMigrations(markAsLegacyAndChangeConsumer, setAlertIdAsDefaultDedupkeyOnPagerDutyActions)
);
return {
'7.10.0': executeMigrationWithErrorHandling(migrationWhenRBACWasIntroduced, '7.10.0'),
@ -52,29 +62,55 @@ const consumersToChange: Map<string, string> = new Map(
[SIEM_APP_ID]: SIEM_SERVER_APP_ID,
})
);
function markAsLegacyAndChangeConsumer(
encryptedSavedObjects: EncryptedSavedObjectsPluginSetup
): SavedObjectMigrationFn<RawAlert, RawAlert> {
return encryptedSavedObjects.createMigration<RawAlert, RawAlert>(
function shouldBeMigrated(doc): doc is SavedObjectUnsanitizedDoc<RawAlert> {
// migrate all documents in 7.10 in order to add the "meta" RBAC field
return true;
doc: SavedObjectUnsanitizedDoc<RawAlert>
): SavedObjectUnsanitizedDoc<RawAlert> {
const {
attributes: { consumer },
} = doc;
return {
...doc,
attributes: {
...doc.attributes,
consumer: consumersToChange.get(consumer) ?? consumer,
// mark any alert predating 7.10 as a legacy alert
meta: {
versionApiKeyLastmodified: LEGACY_LAST_MODIFIED_VERSION,
},
},
(doc: SavedObjectUnsanitizedDoc<RawAlert>): SavedObjectUnsanitizedDoc<RawAlert> => {
const {
attributes: { consumer },
} = doc;
return {
...doc,
attributes: {
...doc.attributes,
consumer: consumersToChange.get(consumer) ?? consumer,
// mark any alert predating 7.10 as a legacy alert
meta: {
versionApiKeyLastmodified: LEGACY_LAST_MODIFIED_VERSION,
},
},
};
}
);
};
}
function setAlertIdAsDefaultDedupkeyOnPagerDutyActions(
doc: SavedObjectUnsanitizedDoc<RawAlert>
): SavedObjectUnsanitizedDoc<RawAlert> {
const { attributes } = doc;
return {
...doc,
attributes: {
...attributes,
...(attributes.actions
? {
actions: attributes.actions.map((action) => {
if (action.actionTypeId !== '.pagerduty' || action.params.eventAction === 'trigger') {
return action;
}
return {
...action,
params: {
...action.params,
dedupKey: action.params.dedupKey ?? '{{alertId}}',
},
};
}),
}
: {}),
},
};
}
function pipeMigrations(...migrations: AlertMigration[]): AlertMigration {
return (doc: SavedObjectUnsanitizedDoc<RawAlert>) =>
migrations.reduce((migratedDoc, nextMigration) => nextMigration(migratedDoc), doc);
}

View file

@ -91,6 +91,7 @@ describe('pagerduty action params validation', () => {
expect(actionTypeModel.validateParams(actionParams)).toEqual({
errors: {
dedupKey: [],
summary: [],
timestamp: [],
},

View file

@ -50,8 +50,22 @@ export function getActionType(): ActionTypeModel {
const errors = {
summary: new Array<string>(),
timestamp: new Array<string>(),
dedupKey: new Array<string>(),
};
validationResult.errors = errors;
if (
!actionParams.dedupKey?.length &&
(actionParams.eventAction === 'resolve' || actionParams.eventAction === 'acknowledge')
) {
errors.dedupKey.push(
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.error.requiredDedupKeyText',
{
defaultMessage: 'DedupKey is required when resolving or acknowledging an incident.',
}
)
);
}
if (!actionParams.summary?.length) {
errors.summary.push(
i18n.translate(

View file

@ -26,7 +26,7 @@ describe('PagerDutyParamsFields renders', () => {
const wrapper = mountWithIntl(
<PagerDutyParamsFields
actionParams={actionParams}
errors={{ summary: [], timestamp: [] }}
errors={{ summary: [], timestamp: [], dedupKey: [] }}
editAction={() => {}}
index={0}
docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart}

View file

@ -94,6 +94,9 @@ const PagerDutyParamsFields: React.FunctionComponent<ActionParamsProps<PagerDuty
),
},
];
const isDedupeKeyRequired = eventAction !== 'trigger';
return (
<Fragment>
<EuiFlexGroup>
@ -144,12 +147,23 @@ const PagerDutyParamsFields: React.FunctionComponent<ActionParamsProps<PagerDuty
<EuiFlexItem>
<EuiFormRow
fullWidth
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.dedupKeyTextFieldLabel',
{
defaultMessage: 'DedupKey (optional)',
}
)}
error={errors.dedupKey}
isInvalid={errors.dedupKey.length > 0}
label={
isDedupeKeyRequired
? i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.dedupKeyTextRequiredFieldLabel',
{
defaultMessage: 'DedupKey',
}
)
: i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.dedupKeyTextFieldLabel',
{
defaultMessage: 'DedupKey (optional)',
}
)
}
>
<TextFieldWithMessageVariables
index={index}

View file

@ -23,7 +23,7 @@ export function initPlugin(router: IRouter, path: string) {
validate: {
body: schema.object(
{
dedup_key: schema.string(),
dedup_key: schema.maybe(schema.string()),
payload: schema.object(
{
summary: schema.string(),
@ -48,12 +48,7 @@ export function initPlugin(router: IRouter, path: string) {
res: KibanaResponseFactory
): Promise<IKibanaResponse<any>> {
const { body } = req;
let dedupKey = body && body.dedup_key;
const summary = body && body.payload && body.payload.summary;
if (dedupKey == null) {
dedupKey = `kibana-ft-simulator-dedup-key-${new Date().toISOString()}`;
}
const summary = body?.payload?.summary;
switch (summary) {
case 'respond-with-429':
@ -67,7 +62,7 @@ export function initPlugin(router: IRouter, path: string) {
return jsonResponse(res, 202, {
status: 'success',
message: 'Event processed',
dedup_key: dedupKey,
...(body?.dedup_key ? { dedup_key: body?.dedup_key } : {}),
});
}
);

View file

@ -163,7 +163,6 @@ export default function pagerdutyTest({ getService }: FtrProviderContext) {
status: 'ok',
actionId: simulatedActionId,
data: {
dedup_key: `action:${simulatedActionId}`,
message: 'Event processed',
status: 'success',
},

View file

@ -39,5 +39,48 @@ export default function createGetTests({ getService }: FtrProviderContext) {
expect(response.status).to.eql(200);
expect(response.body.consumer).to.equal('infrastructure');
});
it('7.10.0 migrates PagerDuty actions to have a default dedupKey', async () => {
const response = await supertest.get(
`${getUrlPrefix(``)}/api/alerts/alert/b6087f72-994f-46fb-8120-c6e5c50d0f8f`
);
expect(response.status).to.eql(200);
expect(response.body.actions).to.eql([
{
actionTypeId: '.pagerduty',
id: 'a6a8ab7a-35cf-445e-ade3-215a029c2ee3',
group: 'default',
params: {
component: '',
eventAction: 'trigger',
summary: 'fired {{alertInstanceId}}',
},
},
{
actionTypeId: '.pagerduty',
id: 'a6a8ab7a-35cf-445e-ade3-215a029c2ee3',
group: 'default',
params: {
component: '',
dedupKey: '{{alertId}}',
eventAction: 'resolve',
summary: 'fired {{alertInstanceId}}',
},
},
{
actionTypeId: '.pagerduty',
id: 'a6a8ab7a-35cf-445e-ade3-215a029c2ee3',
group: 'default',
params: {
component: '',
dedupKey: '{{alertInstanceId}}',
eventAction: 'resolve',
summary: 'fired {{alertInstanceId}}',
},
},
]);
});
});
}

View file

@ -80,4 +80,115 @@
"updated_at": "2020-06-17T15:35:39.839Z"
}
}
}
{
"type": "doc",
"value": {
"id": "action:a6a8ab7a-35cf-445e-ade3-215a029c2ee3",
"index": ".kibana_1",
"source": {
"action": {
"actionTypeId": ".pagerduty",
"config": {
"apiUrl": "http://elastic:changeme@localhost:5620/api/_actions-FTS-external-service-simulators/pagerduty"
},
"name": "A pagerduty action",
"secrets": "kvjaTWYKGmCqptyv4giaN+nQGgsZrKXmlULcbAP8KK3JmR8Ei9ADqh5mB+uVC+x+Q7/vTQ5SKZCj3dHv3pmNzZ5WGyZYQFBaaa63Mkp3kIcnpE1OdSAv+3Z/Y+XihHAM19zUm3JRpojnIpYegoS5/vMx1sOzcf/+miYUuZw2lgo0lNE="
},
"references": [
],
"type": "action",
"updated_at": "2020-09-22T15:16:06.924Z"
}
}
}
{
"type": "doc",
"value": {
"id": "alert:b6087f72-994f-46fb-8120-c6e5c50d0f8f",
"index": ".kibana_1",
"source": {
"alert": {
"actions": [
{
"actionRef": "action_0",
"actionTypeId": ".pagerduty",
"group": "default",
"params": {
"component": "",
"eventAction": "trigger",
"summary": "fired {{alertInstanceId}}"
}
},
{
"actionRef": "action_1",
"actionTypeId": ".pagerduty",
"group": "default",
"params": {
"component": "",
"eventAction": "resolve",
"summary": "fired {{alertInstanceId}}"
}
},
{
"actionRef": "action_2",
"actionTypeId": ".pagerduty",
"group": "default",
"params": {
"component": "",
"dedupKey": "{{alertInstanceId}}",
"eventAction": "resolve",
"summary": "fired {{alertInstanceId}}"
}
}
],
"alertTypeId": "test.noop",
"apiKey": null,
"apiKeyOwner": null,
"consumer": "alertsFixture",
"createdAt": "2020-09-22T15:16:07.451Z",
"createdBy": null,
"enabled": true,
"muteAll": false,
"mutedInstanceIds": [
],
"name": "abc",
"params": {
},
"schedule": {
"interval": "1m"
},
"scheduledTaskId": "8a7c6ff0-fce6-11ea-a888-9337d77a2c25",
"tags": [
"foo"
],
"throttle": "1m",
"updatedBy": null
},
"migrationVersion": {
"alert": "7.9.0"
},
"references": [
{
"id": "a6a8ab7a-35cf-445e-ade3-215a029c2ee3",
"name": "action_0",
"type": "action"
},
{
"id": "a6a8ab7a-35cf-445e-ade3-215a029c2ee3",
"name": "action_1",
"type": "action"
},
{
"id": "a6a8ab7a-35cf-445e-ade3-215a029c2ee3",
"name": "action_2",
"type": "action"
}
],
"type": "alert",
"updated_at": "2020-09-22T15:16:08.456Z"
}
}
}