Skip to content

Commit

Permalink
[RAM] Add Snooze UI and Unsnooze API (#128214)
Browse files Browse the repository at this point in the history
* Add Snooze UI and Unsnooze API

* Add unsnooze writeoperation

* Add unsnooze API tests

* Add UI tests

* Add tooltip and enable canceling snooze when clicking Enabled

* Fix rulesClient mock

Co-authored-by: Kibana Machine <[email protected]>
  • Loading branch information
Zacqary and kibanamachine authored Mar 24, 2022
1 parent 51e0845 commit d102213
Show file tree
Hide file tree
Showing 31 changed files with 1,422 additions and 95 deletions.
9 changes: 9 additions & 0 deletions docs/user/security/audit-logging.asciidoc
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,15 @@ Refer to the corresponding {es} logs for potential write errors.
| `unknown` | User is updating an alert.
| `failure` | User is not authorized to update an alert.

.2+| `rule_snooze`
| `unknown` | User is snoozing a rule.
| `failure` | User is not authorized to snooze a rule.

.2+| `rule_unsnooze`
| `unknown` | User is unsnoozing a rule.
| `failure` | User is not authorized to unsnooze a rule.


3+a|
====== Type: deletion

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export enum WriteOperations {
MuteAlert = 'muteAlert',
UnmuteAlert = 'unmuteAlert',
Snooze = 'snooze',
Unsnooze = 'unsnooze',
}

export interface EnsureAuthorizedOpts {
Expand Down
2 changes: 2 additions & 0 deletions x-pack/plugins/alerting/server/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { unmuteAllRuleRoute } from './unmute_all_rule';
import { unmuteAlertRoute } from './unmute_alert';
import { updateRuleApiKeyRoute } from './update_rule_api_key';
import { snoozeRuleRoute } from './snooze_rule';
import { unsnoozeRuleRoute } from './unsnooze_rule';

export interface RouteOptions {
router: IRouter<AlertingRequestHandlerContext>;
Expand Down Expand Up @@ -65,4 +66,5 @@ export function defineRoutes(opts: RouteOptions) {
unmuteAlertRoute(router, licenseState);
updateRuleApiKeyRoute(router, licenseState);
snoozeRuleRoute(router, licenseState);
unsnoozeRuleRoute(router, licenseState);
}
11 changes: 2 additions & 9 deletions x-pack/plugins/alerting/server/routes/snooze_rule.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,10 @@ beforeEach(() => {
jest.resetAllMocks();
});

const SNOOZE_END_TIME = '2025-03-07T00:00:00.000Z';
// These tests don't test for future snooze time validation, so this date doesn't need to be in the future
const SNOOZE_END_TIME = '2021-03-07T00:00:00.000Z';

describe('snoozeAlertRoute', () => {
beforeAll(() => {
jest.useFakeTimers('modern');
jest.setSystemTime(new Date(2020, 3, 1));
});

afterAll(() => {
jest.useRealTimers();
});
it('snoozes an alert', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
Expand Down
80 changes: 80 additions & 0 deletions x-pack/plugins/alerting/server/routes/unsnooze_rule.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/*
* 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 { unsnoozeRuleRoute } from './unsnooze_rule';
import { httpServiceMock } from 'src/core/server/mocks';
import { licenseStateMock } from '../lib/license_state.mock';
import { mockHandlerArguments } from './_mock_handler_arguments';
import { rulesClientMock } from '../rules_client.mock';
import { AlertTypeDisabledError } from '../lib/errors/alert_type_disabled';

const rulesClient = rulesClientMock.create();
jest.mock('../lib/license_api_access.ts', () => ({
verifyApiAccess: jest.fn(),
}));

beforeEach(() => {
jest.resetAllMocks();
});

describe('unsnoozeAlertRoute', () => {
it('unsnoozes an alert', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();

unsnoozeRuleRoute(router, licenseState);

const [config, handler] = router.post.mock.calls[0];

expect(config.path).toMatchInlineSnapshot(`"/internal/alerting/rule/{id}/_unsnooze"`);

rulesClient.unsnooze.mockResolvedValueOnce();

const [context, req, res] = mockHandlerArguments(
{ rulesClient },
{
params: {
id: '1',
},
},
['noContent']
);

expect(await handler(context, req, res)).toEqual(undefined);

expect(rulesClient.unsnooze).toHaveBeenCalledTimes(1);
expect(rulesClient.unsnooze.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"id": "1",
},
]
`);

expect(res.noContent).toHaveBeenCalled();
});

it('ensures the rule type gets validated for the license', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();

unsnoozeRuleRoute(router, licenseState);

const [, handler] = router.post.mock.calls[0];

rulesClient.unsnooze.mockRejectedValue(new AlertTypeDisabledError('Fail', 'license_invalid'));

const [context, req, res] = mockHandlerArguments({ rulesClient }, { params: {}, body: {} }, [
'ok',
'forbidden',
]);

await handler(context, req, res);

expect(res.forbidden).toHaveBeenCalledWith({ body: { message: 'Fail' } });
});
});
45 changes: 45 additions & 0 deletions x-pack/plugins/alerting/server/routes/unsnooze_rule.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/*
* 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 { IRouter } from 'kibana/server';
import { schema } from '@kbn/config-schema';
import { ILicenseState, RuleMutedError } from '../lib';
import { verifyAccessAndContext } from './lib';
import { AlertingRequestHandlerContext, INTERNAL_BASE_ALERTING_API_PATH } from '../types';

const paramSchema = schema.object({
id: schema.string(),
});

export const unsnoozeRuleRoute = (
router: IRouter<AlertingRequestHandlerContext>,
licenseState: ILicenseState
) => {
router.post(
{
path: `${INTERNAL_BASE_ALERTING_API_PATH}/rule/{id}/_unsnooze`,
validate: {
params: paramSchema,
},
},
router.handleLegacyErrors(
verifyAccessAndContext(licenseState, async function (context, req, res) {
const rulesClient = context.alerting.getRulesClient();
const params = req.params;
try {
await rulesClient.unsnooze({ ...params });
return res.noContent();
} catch (e) {
if (e instanceof RuleMutedError) {
return e.sendResponse(res);
}
throw e;
}
})
)
);
};
1 change: 1 addition & 0 deletions x-pack/plugins/alerting/server/rules_client.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const createRulesClientMock = () => {
getExecutionLogForRule: jest.fn(),
getSpaceId: jest.fn(),
snooze: jest.fn(),
unsnooze: jest.fn(),
};
return mocked;
};
Expand Down
3 changes: 3 additions & 0 deletions x-pack/plugins/alerting/server/rules_client/audit_events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export enum RuleAuditAction {
AGGREGATE = 'rule_aggregate',
GET_EXECUTION_LOG = 'rule_get_execution_log',
SNOOZE = 'rule_snooze',
UNSNOOZE = 'rule_unsnooze',
}

type VerbsTuple = [string, string, string];
Expand All @@ -50,6 +51,7 @@ const eventVerbs: Record<RuleAuditAction, VerbsTuple> = {
'accessed execution log for',
],
rule_snooze: ['snooze', 'snoozing', 'snoozed'],
rule_unsnooze: ['unsnooze', 'unsnoozing', 'unsnoozed'],
};

const eventTypes: Record<RuleAuditAction, EcsEventType> = {
Expand All @@ -69,6 +71,7 @@ const eventTypes: Record<RuleAuditAction, EcsEventType> = {
rule_aggregate: 'access',
rule_get_execution_log: 'access',
rule_snooze: 'change',
rule_unsnooze: 'change',
};

export interface RuleAuditEventParams {
Expand Down
62 changes: 62 additions & 0 deletions x-pack/plugins/alerting/server/rules_client/rules_client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1682,6 +1682,68 @@ export class RulesClient {
);
}

public async unsnooze({ id }: { id: string }): Promise<void> {
return await retryIfConflicts(
this.logger,
`rulesClient.unsnooze('${id}')`,
async () => await this.unsnoozeWithOCC({ id })
);
}

private async unsnoozeWithOCC({ id }: { id: string }) {
const { attributes, version } = await this.unsecuredSavedObjectsClient.get<RawRule>(
'alert',
id
);

try {
await this.authorization.ensureAuthorized({
ruleTypeId: attributes.alertTypeId,
consumer: attributes.consumer,
operation: WriteOperations.Unsnooze,
entity: AlertingAuthorizationEntity.Rule,
});

if (attributes.actions.length) {
await this.actionsAuthorization.ensureAuthorized('execute');
}
} catch (error) {
this.auditLogger?.log(
ruleAuditEvent({
action: RuleAuditAction.UNSNOOZE,
savedObject: { type: 'alert', id },
error,
})
);
throw error;
}

this.auditLogger?.log(
ruleAuditEvent({
action: RuleAuditAction.UNSNOOZE,
outcome: 'unknown',
savedObject: { type: 'alert', id },
})
);

this.ruleTypeRegistry.ensureRuleTypeEnabled(attributes.alertTypeId);

const updateAttributes = this.updateMeta({
snoozeEndTime: null,
muteAll: false,
updatedBy: await this.getUserName(),
updatedAt: new Date().toISOString(),
});
const updateOptions = { version };

await partiallyUpdateAlert(
this.unsecuredSavedObjectsClient,
id,
updateAttributes,
updateOptions
);
}

public async muteAll({ id }: { id: string }): Promise<void> {
return await retryIfConflicts(
this.logger,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ describe(`feature_privilege_builder`, () => {
"alerting:1.0.0-zeta1:alert-type/my-feature/rule/muteAlert",
"alerting:1.0.0-zeta1:alert-type/my-feature/rule/unmuteAlert",
"alerting:1.0.0-zeta1:alert-type/my-feature/rule/snooze",
"alerting:1.0.0-zeta1:alert-type/my-feature/rule/unsnooze",
]
`);
});
Expand Down Expand Up @@ -321,6 +322,7 @@ describe(`feature_privilege_builder`, () => {
"alerting:1.0.0-zeta1:alert-type/my-feature/rule/muteAlert",
"alerting:1.0.0-zeta1:alert-type/my-feature/rule/unmuteAlert",
"alerting:1.0.0-zeta1:alert-type/my-feature/rule/snooze",
"alerting:1.0.0-zeta1:alert-type/my-feature/rule/unsnooze",
"alerting:1.0.0-zeta1:alert-type/my-feature/alert/get",
"alerting:1.0.0-zeta1:alert-type/my-feature/alert/find",
"alerting:1.0.0-zeta1:alert-type/my-feature/alert/update",
Expand Down Expand Up @@ -376,6 +378,7 @@ describe(`feature_privilege_builder`, () => {
"alerting:1.0.0-zeta1:alert-type/my-feature/rule/muteAlert",
"alerting:1.0.0-zeta1:alert-type/my-feature/rule/unmuteAlert",
"alerting:1.0.0-zeta1:alert-type/my-feature/rule/snooze",
"alerting:1.0.0-zeta1:alert-type/my-feature/rule/unsnooze",
"alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/get",
"alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/getRuleState",
"alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/getAlertSummary",
Expand Down Expand Up @@ -478,6 +481,7 @@ describe(`feature_privilege_builder`, () => {
"alerting:1.0.0-zeta1:alert-type/my-feature/rule/muteAlert",
"alerting:1.0.0-zeta1:alert-type/my-feature/rule/unmuteAlert",
"alerting:1.0.0-zeta1:alert-type/my-feature/rule/snooze",
"alerting:1.0.0-zeta1:alert-type/my-feature/rule/unsnooze",
"alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/get",
"alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/getRuleState",
"alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/getAlertSummary",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const writeOperations: Record<AlertingEntity, string[]> = {
'muteAlert',
'unmuteAlert',
'snooze',
'unsnooze',
],
alert: ['update'],
};
Expand Down
2 changes: 0 additions & 2 deletions x-pack/plugins/translations/translations/ja-JP.json
Original file line number Diff line number Diff line change
Expand Up @@ -27874,9 +27874,7 @@
"xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.durationTitle": "ルールを実行するのにかかる時間。",
"xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.editAriaLabel": "編集",
"xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.editButtonTooltip": "編集",
"xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.enabledTitle": "有効",
"xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.lastExecutionDateTitle": "前回の実行の開始時間。",
"xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.mutedBadge": "ミュート",
"xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.nameTitle": "名前",
"xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.scheduleTitle": "間隔",
"xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.statusTitle": "ステータス",
Expand Down
2 changes: 0 additions & 2 deletions x-pack/plugins/translations/translations/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -27905,9 +27905,7 @@
"xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.durationTitle": "运行规则所需的时间长度。",
"xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.editAriaLabel": "编辑",
"xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.editButtonTooltip": "编辑",
"xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.enabledTitle": "已启用",
"xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.lastExecutionDateTitle": "上次执行的开始时间。",
"xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.mutedBadge": "已静音",
"xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.nameTitle": "名称",
"xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.scheduleTitle": "时间间隔",
"xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.statusTitle": "状态",
Expand Down
2 changes: 2 additions & 0 deletions x-pack/plugins/triggers_actions_ui/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,5 @@

export * from './data';
export const BASE_TRIGGERS_ACTIONS_UI_API_PATH = '/api/triggers_actions_ui';
export * from './parse_interval';
export * from './experimental_features';
28 changes: 28 additions & 0 deletions x-pack/plugins/triggers_actions_ui/common/parse_interval.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/*
* 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 dateMath from '@elastic/datemath';
import { i18n } from '@kbn/i18n';
export const INTERVAL_STRING_RE = new RegExp(`^([\\d\\.]+)\\s*(${dateMath.units.join('|')})$`);

export const parseInterval = (intervalString: string) => {
if (intervalString) {
const matches = intervalString.match(INTERVAL_STRING_RE);
if (matches) {
const value = Number(matches[1]);
const unit = matches[2];
return { value, unit };
}
}
throw new Error(
i18n.translate('xpack.triggersActionsUI.parseInterval.errorMessage', {
defaultMessage: '{value} is not an interval string',
values: {
value: intervalString,
},
})
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export const transformRule: RewriteRequestCase<Rule> = ({
scheduled_task_id: scheduledTaskId,
execution_status: executionStatus,
actions: actions,
snooze_end_time: snoozeEndTime,
...rest
}: any) => ({
ruleTypeId,
Expand All @@ -54,6 +55,7 @@ export const transformRule: RewriteRequestCase<Rule> = ({
notifyWhen,
muteAll,
mutedInstanceIds,
snoozeEndTime,
executionStatus: executionStatus ? transformExecutionStatus(executionStatus) : undefined,
actions: actions
? actions.map((action: AsApiContract<RuleAction>) => transformAction(action))
Expand Down
Loading

0 comments on commit d102213

Please sign in to comment.