Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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');
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So it turns out Jest fake timers doesn't actually work for mocking the output of Date or moment several imports deep. We just didn't realize this because the snooze_rule test isn't actually validating that the snooze date is in the future.

I was able to use jest.spyOn to mock Date.now() for the dropdown UI tests, but I haven't been able to find a reliable solution to mock new Date() or moment() with no arguments. We can work around this by using new Date(Date.now() or moment(Date.now()) in any code where we want to test against the current date.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd love to help figure out a way, but I don't know if I understand the problem. Can you elaborate more on what you're trying to do and what's not working?

Copy link
Copy Markdown
Contributor Author

@Zacqary Zacqary Mar 24, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's code in the snooze API that uses Moment to determine if the snoozeEndTime is in the future:

moment().isBefore(snoozeEndTime);

And also in the new RuleStatusDropdown to determine how far in the future snoozeEndTime is:

moment(item.snoozeEndTime).fromNow(true)

We thought that using jest.useFakeTimers would enable us to change the return result of new Date() and Date.now(). For example, in this test we called jest.setSystemTime(new Date(2020, 3, 1)) to set the value of now to March 2020 (which, now that I think about it, oh god, why would anyone want to make anything permanently March 2020, oh no oh no oh no).

Turns out, this did not actually work as expected. It seems like Jest's fake timers system only applies to Date() called within the Jest tests themselves, and not to any modules that the tests import.

We didn't realize that this was broken because snooze_rule.test.ts isn't actually testing the part of the snooze API that tries to check if the snooze end time is in the future. But we thought that maybe it was.

It appears that moment.fromNow() uses Date.now() to determine the value of now, as opposed to moment([no arguments]) which uses new Date(). jest.spyOn allows us to mock the value of global.Date.now throughout all imports, but mocking global.Date requires you to mock the entire Date object and there's no easy way to just override the constructor.

So therefore, we can work around that limitation by doing something like:

moment(Date.now()).isBefore(snoozeEndTime);

in any code that we need to test.

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 });
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just for my own information, we feel comfortable spreading the parameters here because the schema should ensure there isn't anything extra in the object?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think so. This is how all the other routes do it.

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 @@ -1659,6 +1659,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,
});
Comment on lines +1670 to +1682
Copy link
Copy Markdown
Contributor

@jportner jportner Mar 22, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't tested this, but it appears that the authorization check will never occur if: 1. the user tries to unsnooze an alert, and 2. the alert is not found. In that case it will throw a 404 error before the authZ check occurs (and thus, it will never get audited). At least that's what it appears to be doing.

It looks like the rest of the rules client functions the same way, too. Can you confirm? CC @XavierM


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,
Comment thread
Zacqary marked this conversation as resolved.
muteAll: false,
updatedBy: await this.getUserName(),
updatedAt: new Date().toISOString(),
});
const updateOptions = { version };
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't seem to pass this in when update the rule from the task runner - why is this different here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No idea I'm just copypasting from what the snooze method does, and that was in turn copypasted from the muteAll method, so I assume it does something for some reason


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 @@ -27878,9 +27878,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 @@ -27909,9 +27909,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