[RAM] Add _snooze API (#127081)

* [RAM] Add _snooze API

* Switch empty snoozeEndTime to -1

* Convert API to internal

* Ensure snoozeEndTime is in the future

* Update x-pack/plugins/alerting/server/routes/snooze_rule.ts

Co-authored-by: Gidi Meir Morris <github@gidi.io>

* Add integration tests for snooze API

* Fix tests

Co-authored-by: Gidi Meir Morris <github@gidi.io>
This commit is contained in:
Zacqary Adam Xeper 2022-03-18 13:22:27 -05:00 committed by GitHub
parent 5dc8e4b638
commit a467450a47
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 922 additions and 8 deletions

View file

@ -94,6 +94,7 @@ export interface Alert<Params extends AlertTypeParams = never> {
mutedInstanceIds: string[];
executionStatus: AlertExecutionStatus;
monitoring?: RuleMonitoring;
snoozeEndTime?: Date | null; // Remove ? when this parameter is made available in the public API
}
export type SanitizedAlert<Params extends AlertTypeParams = never> = Omit<Alert<Params>, 'apiKey'>;

View file

@ -44,6 +44,7 @@ export enum WriteOperations {
UnmuteAll = 'unmuteAll',
MuteAlert = 'muteAlert',
UnmuteAlert = 'unmuteAlert',
Snooze = 'snooze',
}
export interface EnsureAuthorizedOpts {

View file

@ -18,3 +18,4 @@ export type { ErrorThatHandlesItsOwnResponse, ElasticsearchError };
export { getEsErrorMessage };
export type { AlertTypeDisabledReason } from './alert_type_disabled';
export { AlertTypeDisabledError } from './alert_type_disabled';
export { RuleMutedError } from './rule_muted';

View file

@ -0,0 +1,19 @@
/*
* 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 { KibanaResponseFactory } from '../../../../../../src/core/server';
import { ErrorThatHandlesItsOwnResponse } from './types';
export class RuleMutedError extends Error implements ErrorThatHandlesItsOwnResponse {
constructor(message: string) {
super(message);
}
public sendResponse(res: KibanaResponseFactory) {
return res.badRequest({ body: { message: this.message } });
}
}

View file

@ -17,7 +17,7 @@ export type {
ErrorThatHandlesItsOwnResponse,
ElasticsearchError,
} from './errors';
export { AlertTypeDisabledError, isErrorThatHandlesItsOwnResponse } from './errors';
export { AlertTypeDisabledError, RuleMutedError, isErrorThatHandlesItsOwnResponse } from './errors';
export {
executionStatusFromState,
executionStatusFromError,

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const validateSnoozeDate = (date: string) => {
const parsedValue = Date.parse(date);
if (isNaN(parsedValue)) return `Invalid date: ${date}`;
if (parsedValue <= Date.now()) return `Invalid snooze date as it is in the past: ${date}`;
return;
};

View file

@ -84,6 +84,7 @@ const rewriteBodyRes: RewriteResponseCase<FindResult<AlertTypeParams>> = ({
executionStatus,
actions,
scheduledTaskId,
snoozeEndTime,
...rest
}) => ({
...rest,
@ -97,6 +98,8 @@ const rewriteBodyRes: RewriteResponseCase<FindResult<AlertTypeParams>> = ({
mute_all: muteAll,
muted_alert_ids: mutedInstanceIds,
scheduled_task_id: scheduledTaskId,
// Remove this object spread boolean check after snoozeEndTime is added to the public API
...(snoozeEndTime !== undefined ? { snooze_end_time: snoozeEndTime } : {}),
execution_status: executionStatus && {
...omit(executionStatus, 'lastExecutionDate', 'lastDuration'),
last_execution_date: executionStatus.lastExecutionDate,

View file

@ -35,6 +35,7 @@ const rewriteBodyRes: RewriteResponseCase<SanitizedAlert<AlertTypeParams>> = ({
executionStatus,
actions,
scheduledTaskId,
snoozeEndTime,
...rest
}) => ({
...rest,
@ -47,6 +48,8 @@ const rewriteBodyRes: RewriteResponseCase<SanitizedAlert<AlertTypeParams>> = ({
notify_when: notifyWhen,
mute_all: muteAll,
muted_alert_ids: mutedInstanceIds,
// Remove this object spread boolean check after snoozeEndTime is added to the public API
...(snoozeEndTime !== undefined ? { snooze_end_time: snoozeEndTime } : {}),
scheduled_task_id: scheduledTaskId,
execution_status: executionStatus && {
...omit(executionStatus, 'lastExecutionDate', 'lastDuration'),

View file

@ -29,6 +29,7 @@ import { muteAlertRoute } from './mute_alert';
import { unmuteAllRuleRoute } from './unmute_all_rule';
import { unmuteAlertRoute } from './unmute_alert';
import { updateRuleApiKeyRoute } from './update_rule_api_key';
import { snoozeRuleRoute } from './snooze_rule';
export interface RouteOptions {
router: IRouter<AlertingRequestHandlerContext>;
@ -61,4 +62,5 @@ export function defineRoutes(opts: RouteOptions) {
unmuteAllRuleRoute(router, licenseState);
unmuteAlertRoute(router, licenseState);
updateRuleApiKeyRoute(router, licenseState);
snoozeRuleRoute(router, licenseState);
}

View file

@ -0,0 +1,134 @@
/*
* 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 { snoozeRuleRoute } from './snooze_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();
});
const SNOOZE_END_TIME = '2025-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();
snoozeRuleRoute(router, licenseState);
const [config, handler] = router.post.mock.calls[0];
expect(config.path).toMatchInlineSnapshot(`"/internal/alerting/rule/{id}/_snooze"`);
rulesClient.snooze.mockResolvedValueOnce();
const [context, req, res] = mockHandlerArguments(
{ rulesClient },
{
params: {
id: '1',
},
body: {
snooze_end_time: SNOOZE_END_TIME,
},
},
['noContent']
);
expect(await handler(context, req, res)).toEqual(undefined);
expect(rulesClient.snooze).toHaveBeenCalledTimes(1);
expect(rulesClient.snooze.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"id": "1",
"snoozeEndTime": "${SNOOZE_END_TIME}",
},
]
`);
expect(res.noContent).toHaveBeenCalled();
});
it('also snoozes an alert when passed snoozeEndTime of -1', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
snoozeRuleRoute(router, licenseState);
const [config, handler] = router.post.mock.calls[0];
expect(config.path).toMatchInlineSnapshot(`"/internal/alerting/rule/{id}/_snooze"`);
rulesClient.snooze.mockResolvedValueOnce();
const [context, req, res] = mockHandlerArguments(
{ rulesClient },
{
params: {
id: '1',
},
body: {
snooze_end_time: -1,
},
},
['noContent']
);
expect(await handler(context, req, res)).toEqual(undefined);
expect(rulesClient.snooze).toHaveBeenCalledTimes(1);
expect(rulesClient.snooze.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"id": "1",
"snoozeEndTime": -1,
},
]
`);
expect(res.noContent).toHaveBeenCalled();
});
it('ensures the rule type gets validated for the license', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
snoozeRuleRoute(router, licenseState);
const [, handler] = router.post.mock.calls[0];
rulesClient.snooze.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' } });
});
});

View file

@ -0,0 +1,62 @@
/*
* 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, RewriteRequestCase } from './lib';
import { SnoozeOptions } from '../rules_client';
import { AlertingRequestHandlerContext, INTERNAL_BASE_ALERTING_API_PATH } from '../types';
import { validateSnoozeDate } from '../lib/validate_snooze_date';
const paramSchema = schema.object({
id: schema.string(),
});
const bodySchema = schema.object({
snooze_end_time: schema.oneOf([
schema.string({
validate: validateSnoozeDate,
}),
schema.literal(-1),
]),
});
const rewriteBodyReq: RewriteRequestCase<SnoozeOptions> = ({ snooze_end_time: snoozeEndTime }) => ({
snoozeEndTime,
});
export const snoozeRuleRoute = (
router: IRouter<AlertingRequestHandlerContext>,
licenseState: ILicenseState
) => {
router.post(
{
path: `${INTERNAL_BASE_ALERTING_API_PATH}/rule/{id}/_snooze`,
validate: {
params: paramSchema,
body: bodySchema,
},
},
router.handleLegacyErrors(
verifyAccessAndContext(licenseState, async function (context, req, res) {
const rulesClient = context.alerting.getRulesClient();
const params = req.params;
const body = rewriteBodyReq(req.body);
try {
await rulesClient.snooze({ ...params, ...body });
return res.noContent();
} catch (e) {
if (e instanceof RuleMutedError) {
return e.sendResponse(res);
}
throw e;
}
})
)
);
};

View file

@ -31,6 +31,7 @@ const createRulesClientMock = () => {
listAlertTypes: jest.fn(),
getAlertSummary: jest.fn(),
getSpaceId: jest.fn(),
snooze: jest.fn(),
};
return mocked;
};

View file

@ -23,6 +23,7 @@ export enum RuleAuditAction {
MUTE_ALERT = 'rule_alert_mute',
UNMUTE_ALERT = 'rule_alert_unmute',
AGGREGATE = 'rule_aggregate',
SNOOZE = 'rule_snooze',
}
type VerbsTuple = [string, string, string];
@ -42,6 +43,7 @@ const eventVerbs: Record<RuleAuditAction, VerbsTuple> = {
rule_alert_mute: ['mute alert of', 'muting alert of', 'muted alert of'],
rule_alert_unmute: ['unmute alert of', 'unmuting alert of', 'unmuted alert of'],
rule_aggregate: ['access', 'accessing', 'accessed'],
rule_snooze: ['snooze', 'snoozing', 'snoozed'],
};
const eventTypes: Record<RuleAuditAction, EcsEventType> = {
@ -59,6 +61,7 @@ const eventTypes: Record<RuleAuditAction, EcsEventType> = {
rule_alert_mute: 'change',
rule_alert_unmute: 'change',
rule_aggregate: 'access',
rule_snooze: 'change',
};
export interface RuleAuditEventParams {

View file

@ -84,6 +84,8 @@ import {
getModifiedSearch,
modifyFilterKueryNode,
} from './lib/mapped_params_utils';
import { validateSnoozeDate } from '../lib/validate_snooze_date';
import { RuleMutedError } from '../lib/errors/rule_muted';
export interface RegistryAlertTypeWithAuth extends RegistryRuleType {
authorizedConsumers: string[];
@ -144,6 +146,10 @@ export interface MuteOptions extends IndexType {
alertInstanceId: string;
}
export interface SnoozeOptions extends IndexType {
snoozeEndTime: string | -1;
}
export interface FindOptions extends IndexType {
perPage?: number;
page?: number;
@ -202,6 +208,7 @@ export interface CreateOptions<Params extends RuleTypeParams> {
| 'mutedInstanceIds'
| 'actions'
| 'executionStatus'
| 'snoozeEndTime'
> & { actions: NormalizedAlertAction[] };
options?: {
id?: string;
@ -260,6 +267,7 @@ export class RulesClient {
private readonly fieldsToExcludeFromPublicApi: Array<keyof SanitizedRule> = [
'monitoring',
'mapped_params',
'snoozeEndTime',
];
constructor({
@ -372,6 +380,7 @@ export class RulesClient {
updatedBy: username,
createdAt: new Date(createTime).toISOString(),
updatedAt: new Date(createTime).toISOString(),
snoozeEndTime: null,
params: updatedParams as RawRule['params'],
muteAll: false,
mutedInstanceIds: [],
@ -1476,6 +1485,85 @@ export class RulesClient {
}
}
public async snooze({
id,
snoozeEndTime,
}: {
id: string;
snoozeEndTime: string | -1;
}): Promise<void> {
if (typeof snoozeEndTime === 'string') {
const snoozeDateValidationMsg = validateSnoozeDate(snoozeEndTime);
if (snoozeDateValidationMsg) {
throw new RuleMutedError(snoozeDateValidationMsg);
}
}
return await retryIfConflicts(
this.logger,
`rulesClient.snooze('${id}', ${snoozeEndTime})`,
async () => await this.snoozeWithOCC({ id, snoozeEndTime })
);
}
private async snoozeWithOCC({ id, snoozeEndTime }: { id: string; snoozeEndTime: string | -1 }) {
const { attributes, version } = await this.unsecuredSavedObjectsClient.get<RawRule>(
'alert',
id
);
try {
await this.authorization.ensureAuthorized({
ruleTypeId: attributes.alertTypeId,
consumer: attributes.consumer,
operation: WriteOperations.MuteAll,
entity: AlertingAuthorizationEntity.Rule,
});
if (attributes.actions.length) {
await this.actionsAuthorization.ensureAuthorized('execute');
}
} catch (error) {
this.auditLogger?.log(
ruleAuditEvent({
action: RuleAuditAction.SNOOZE,
savedObject: { type: 'alert', id },
error,
})
);
throw error;
}
this.auditLogger?.log(
ruleAuditEvent({
action: RuleAuditAction.SNOOZE,
outcome: 'unknown',
savedObject: { type: 'alert', id },
})
);
this.ruleTypeRegistry.ensureRuleTypeEnabled(attributes.alertTypeId);
// If snoozeEndTime is -1, instead mute all
const newAttrs =
snoozeEndTime === -1
? { muteAll: true, snoozeEndTime: null }
: { snoozeEndTime: new Date(snoozeEndTime).toISOString(), muteAll: false };
const updateAttributes = this.updateMeta({
...newAttrs,
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,

View file

@ -294,6 +294,7 @@ describe('create()', () => {
updatedBy: 'elastic',
updatedAt: '2019-02-12T21:01:22.479Z',
muteAll: false,
snoozeEndTime: null,
mutedInstanceIds: [],
actions: [
{
@ -426,6 +427,7 @@ describe('create()', () => {
"schedule": Object {
"interval": "1m",
},
"snoozeEndTime": null,
"tags": Array [
"foo",
],
@ -496,6 +498,7 @@ describe('create()', () => {
updatedBy: 'elastic',
updatedAt: '2019-02-12T21:01:22.479Z',
muteAll: false,
snoozeEndTime: null,
mutedInstanceIds: [],
actions: [
{
@ -555,6 +558,7 @@ describe('create()', () => {
updatedBy: 'elastic',
updatedAt: '2019-02-12T21:01:22.479Z',
muteAll: false,
snoozeEndTime: null,
mutedInstanceIds: [],
actions: [
{
@ -627,6 +631,7 @@ describe('create()', () => {
"schedule": Object {
"interval": "1m",
},
"snoozeEndTime": null,
"tags": Array [
"foo",
],
@ -1034,6 +1039,7 @@ describe('create()', () => {
monitoring: getDefaultRuleMonitoring(),
meta: { versionApiKeyLastmodified: kibanaVersion },
muteAll: false,
snoozeEndTime: null,
mutedInstanceIds: [],
name: 'abc',
notifyWhen: 'onActiveAlert',
@ -1231,6 +1237,7 @@ describe('create()', () => {
monitoring: getDefaultRuleMonitoring(),
meta: { versionApiKeyLastmodified: kibanaVersion },
muteAll: false,
snoozeEndTime: null,
mutedInstanceIds: [],
name: 'abc',
notifyWhen: 'onActiveAlert',
@ -1397,6 +1404,7 @@ describe('create()', () => {
monitoring: getDefaultRuleMonitoring(),
meta: { versionApiKeyLastmodified: kibanaVersion },
muteAll: false,
snoozeEndTime: null,
mutedInstanceIds: [],
name: 'abc',
notifyWhen: 'onActiveAlert',
@ -1505,6 +1513,7 @@ describe('create()', () => {
updatedBy: 'elastic',
updatedAt: '2019-02-12T21:01:22.479Z',
muteAll: false,
snoozeEndTime: null,
mutedInstanceIds: [],
notifyWhen: 'onActionGroupChange',
actions: [
@ -1561,6 +1570,7 @@ describe('create()', () => {
throttle: '10m',
notifyWhen: 'onActionGroupChange',
muteAll: false,
snoozeEndTime: null,
mutedInstanceIds: [],
tags: ['foo'],
executionStatus: {
@ -1634,6 +1644,7 @@ describe('create()', () => {
updatedBy: 'elastic',
updatedAt: '2019-02-12T21:01:22.479Z',
muteAll: false,
snoozeEndTime: null,
mutedInstanceIds: [],
notifyWhen: 'onThrottleInterval',
actions: [
@ -1690,6 +1701,7 @@ describe('create()', () => {
throttle: '10m',
notifyWhen: 'onThrottleInterval',
muteAll: false,
snoozeEndTime: null,
mutedInstanceIds: [],
tags: ['foo'],
executionStatus: {
@ -1763,6 +1775,7 @@ describe('create()', () => {
updatedBy: 'elastic',
updatedAt: '2019-02-12T21:01:22.479Z',
muteAll: false,
snoozeEndTime: null,
mutedInstanceIds: [],
notifyWhen: 'onActiveAlert',
actions: [
@ -1819,6 +1832,7 @@ describe('create()', () => {
throttle: null,
notifyWhen: 'onActiveAlert',
muteAll: false,
snoozeEndTime: null,
mutedInstanceIds: [],
tags: ['foo'],
executionStatus: {
@ -1901,6 +1915,7 @@ describe('create()', () => {
updatedBy: 'elastic',
updatedAt: '2019-02-12T21:01:22.479Z',
muteAll: false,
snoozeEndTime: null,
mutedInstanceIds: [],
actions: [
{
@ -1964,6 +1979,7 @@ describe('create()', () => {
createdAt: '2019-02-12T21:01:22.479Z',
updatedAt: '2019-02-12T21:01:22.479Z',
muteAll: false,
snoozeEndTime: null,
mutedInstanceIds: [],
executionStatus: {
status: 'pending',
@ -2332,6 +2348,7 @@ describe('create()', () => {
throttle: null,
notifyWhen: 'onActiveAlert',
muteAll: false,
snoozeEndTime: null,
mutedInstanceIds: [],
tags: ['foo'],
executionStatus: {
@ -2432,6 +2449,7 @@ describe('create()', () => {
throttle: null,
notifyWhen: 'onActiveAlert',
muteAll: false,
snoozeEndTime: null,
mutedInstanceIds: [],
tags: ['foo'],
executionStatus: {

View file

@ -30,6 +30,7 @@ export const AlertAttributesExcludedFromAAD = [
'updatedAt',
'executionStatus',
'monitoring',
'snoozeEndTime',
];
// useful for Pick<RawAlert, AlertAttributesExcludedFromAADType> which is a
@ -43,7 +44,8 @@ export type AlertAttributesExcludedFromAADType =
| 'updatedBy'
| 'updatedAt'
| 'executionStatus'
| 'monitoring';
| 'monitoring'
| 'snoozeEndTime';
export function setupSavedObjects(
savedObjects: SavedObjectsServiceSetup,

View file

@ -244,7 +244,7 @@ export interface RawRule extends SavedObjectAttributes {
meta?: AlertMeta;
executionStatus: RawRuleExecutionStatus;
monitoring?: RuleMonitoring;
snoozeEndTime?: string;
snoozeEndTime?: string | null; // Remove ? when this parameter is made available in the public API
}
export type AlertInfoParams = Pick<

View file

@ -86,6 +86,17 @@ export class AlertUtils {
return request;
}
public getSnoozeRequest(alertId: string) {
const request = this.supertestWithoutAuth
.post(`${getUrlPrefix(this.space.id)}/internal/alerting/rule/${alertId}/_snooze`)
.set('kbn-xsrf', 'foo')
.set('content-type', 'application/json');
if (this.user) {
return request.auth(this.user.username, this.user.password);
}
return request;
}
public getMuteAllRequest(alertId: string) {
const request = this.supertestWithoutAuth
.post(`${getUrlPrefix(this.space.id)}/api/alerting/rule/${alertId}/_mute_all`)

View file

@ -82,7 +82,9 @@ const findTestUtils = (
mute_all: false,
muted_alert_ids: [],
execution_status: match.execution_status,
...(describeType === 'internal' ? { monitoring: match.monitoring } : {}),
...(describeType === 'internal'
? { monitoring: match.monitoring, snooze_end_time: match.snooze_end_time }
: {}),
});
expect(Date.parse(match.created_at)).to.be.greaterThan(0);
expect(Date.parse(match.updated_at)).to.be.greaterThan(0);
@ -281,7 +283,9 @@ const findTestUtils = (
created_at: match.created_at,
updated_at: match.updated_at,
execution_status: match.execution_status,
...(describeType === 'internal' ? { monitoring: match.monitoring } : {}),
...(describeType === 'internal'
? { monitoring: match.monitoring, snooze_end_time: match.snooze_end_time }
: {}),
});
expect(Date.parse(match.created_at)).to.be.greaterThan(0);
expect(Date.parse(match.updated_at)).to.be.greaterThan(0);

View file

@ -81,7 +81,12 @@ const getTestUtils = (
mute_all: false,
muted_alert_ids: [],
execution_status: response.body.execution_status,
...(describeType === 'internal' ? { monitoring: response.body.monitoring } : {}),
...(describeType === 'internal'
? {
monitoring: response.body.monitoring,
snooze_end_time: response.body.snooze_end_time,
}
: {}),
});
expect(Date.parse(response.body.created_at)).to.be.greaterThan(0);
expect(Date.parse(response.body.updated_at)).to.be.greaterThan(0);

View file

@ -53,6 +53,7 @@ export default function alertingTests({ loadTestFile, getService }: FtrProviderC
loadTestFile(require.resolve('./mustache_templates'));
loadTestFile(require.resolve('./health'));
loadTestFile(require.resolve('./excluded'));
loadTestFile(require.resolve('./snooze'));
});
});
}

View file

@ -0,0 +1,403 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import expect from '@kbn/expect';
import { UserAtSpaceScenarios } from '../../scenarios';
import { FtrProviderContext } from '../../../common/ftr_provider_context';
import {
AlertUtils,
checkAAD,
getUrlPrefix,
getTestRuleData,
ObjectRemover,
getConsumerUnauthorizedErrorMessage,
getProducerUnauthorizedErrorMessage,
} from '../../../common/lib';
const FUTURE_SNOOZE_TIME = '9999-12-31T06:00:00.000Z';
// eslint-disable-next-line import/no-default-export
export default function createSnoozeRuleTests({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const supertestWithoutAuth = getService('supertestWithoutAuth');
describe('snooze', () => {
const objectRemover = new ObjectRemover(supertest);
after(() => objectRemover.removeAll());
for (const scenario of UserAtSpaceScenarios) {
const { user, space } = scenario;
const alertUtils = new AlertUtils({ user, space, supertestWithoutAuth });
describe(scenario.id, () => {
it('should handle snooze rule request appropriately', async () => {
const { body: createdAction } = await supertest
.post(`${getUrlPrefix(space.id)}/api/actions/connector`)
.set('kbn-xsrf', 'foo')
.send({
name: 'MY action',
connector_type_id: 'test.noop',
config: {},
secrets: {},
})
.expect(200);
const { body: createdAlert } = await supertest
.post(`${getUrlPrefix(space.id)}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(
getTestRuleData({
enabled: false,
actions: [
{
id: createdAction.id,
group: 'default',
params: {},
},
],
})
)
.expect(200);
objectRemover.add(space.id, createdAlert.id, 'rule', 'alerting');
const response = await alertUtils
.getSnoozeRequest(createdAlert.id)
.send({ snooze_end_time: FUTURE_SNOOZE_TIME });
switch (scenario.id) {
case 'no_kibana_privileges at space1':
case 'space_1_all at space2':
case 'global_read at space1':
expect(response.statusCode).to.eql(403);
expect(response.body).to.eql({
error: 'Forbidden',
message: getConsumerUnauthorizedErrorMessage(
'muteAll',
'test.noop',
'alertsFixture'
),
statusCode: 403,
});
break;
case 'space_1_all_alerts_none_actions at space1':
expect(response.statusCode).to.eql(403);
expect(response.body).to.eql({
error: 'Forbidden',
message: `Unauthorized to execute actions`,
statusCode: 403,
});
break;
case 'superuser at space1':
case 'space_1_all at space1':
case 'space_1_all_with_restricted_fixture at space1':
expect(response.statusCode).to.eql(204);
expect(response.body).to.eql('');
const { body: updatedAlert } = await supertestWithoutAuth
.get(`${getUrlPrefix(space.id)}/internal/alerting/rule/${createdAlert.id}`)
.set('kbn-xsrf', 'foo')
.auth(user.username, user.password)
.expect(200);
expect(updatedAlert.snooze_end_time).to.eql(FUTURE_SNOOZE_TIME);
expect(updatedAlert.mute_all).to.eql(false);
// Ensure AAD isn't broken
await checkAAD({
supertest,
spaceId: space.id,
type: 'alert',
id: createdAlert.id,
});
break;
default:
throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);
}
});
it('should handle snooze rule request appropriately when consumer is the same as producer', async () => {
const { body: createdAlert } = await supertest
.post(`${getUrlPrefix(space.id)}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(
getTestRuleData({
enabled: false,
rule_type_id: 'test.restricted-noop',
consumer: 'alertsRestrictedFixture',
})
)
.expect(200);
objectRemover.add(space.id, createdAlert.id, 'rule', 'alerting');
const response = await alertUtils
.getSnoozeRequest(createdAlert.id)
.send({ snooze_end_time: FUTURE_SNOOZE_TIME });
switch (scenario.id) {
case 'no_kibana_privileges at space1':
case 'space_1_all at space2':
case 'global_read at space1':
case 'space_1_all at space1':
case 'space_1_all_alerts_none_actions at space1':
expect(response.statusCode).to.eql(403);
expect(response.body).to.eql({
error: 'Forbidden',
message: getConsumerUnauthorizedErrorMessage(
'muteAll',
'test.restricted-noop',
'alertsRestrictedFixture'
),
statusCode: 403,
});
break;
case 'superuser at space1':
case 'space_1_all_with_restricted_fixture at space1':
expect(response.statusCode).to.eql(204);
expect(response.body).to.eql('');
const { body: updatedAlert } = await supertestWithoutAuth
.get(`${getUrlPrefix(space.id)}/internal/alerting/rule/${createdAlert.id}`)
.set('kbn-xsrf', 'foo')
.auth(user.username, user.password)
.expect(200);
expect(updatedAlert.snooze_end_time).to.eql(FUTURE_SNOOZE_TIME);
expect(updatedAlert.mute_all).to.eql(false);
// Ensure AAD isn't broken
await checkAAD({
supertest,
spaceId: space.id,
type: 'alert',
id: createdAlert.id,
});
break;
default:
throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);
}
});
it('should handle snooze rule request appropriately when consumer is not the producer', async () => {
const { body: createdAlert } = await supertest
.post(`${getUrlPrefix(space.id)}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(
getTestRuleData({
enabled: false,
rule_type_id: 'test.unrestricted-noop',
consumer: 'alertsFixture',
})
)
.expect(200);
objectRemover.add(space.id, createdAlert.id, 'rule', 'alerting');
const response = await alertUtils
.getSnoozeRequest(createdAlert.id)
.send({ snooze_end_time: FUTURE_SNOOZE_TIME });
switch (scenario.id) {
case 'no_kibana_privileges at space1':
case 'space_1_all at space2':
case 'global_read at space1':
expect(response.statusCode).to.eql(403);
expect(response.body).to.eql({
error: 'Forbidden',
message: getConsumerUnauthorizedErrorMessage(
'muteAll',
'test.unrestricted-noop',
'alertsFixture'
),
statusCode: 403,
});
break;
case 'space_1_all at space1':
case 'space_1_all_alerts_none_actions at space1':
expect(response.statusCode).to.eql(403);
expect(response.body).to.eql({
error: 'Forbidden',
message: getProducerUnauthorizedErrorMessage(
'muteAll',
'test.unrestricted-noop',
'alertsRestrictedFixture'
),
statusCode: 403,
});
break;
case 'superuser at space1':
case 'space_1_all_with_restricted_fixture at space1':
expect(response.statusCode).to.eql(204);
expect(response.body).to.eql('');
const { body: updatedAlert } = await supertestWithoutAuth
.get(`${getUrlPrefix(space.id)}/internal/alerting/rule/${createdAlert.id}`)
.set('kbn-xsrf', 'foo')
.auth(user.username, user.password)
.expect(200);
expect(updatedAlert.snooze_end_time).to.eql(FUTURE_SNOOZE_TIME);
expect(updatedAlert.mute_all).to.eql(false);
// Ensure AAD isn't broken
await checkAAD({
supertest,
spaceId: space.id,
type: 'alert',
id: createdAlert.id,
});
break;
default:
throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);
}
});
it('should handle snooze rule request appropriately when consumer is "alerts"', async () => {
const { body: createdAlert } = await supertest
.post(`${getUrlPrefix(space.id)}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(
getTestRuleData({
enabled: false,
rule_type_id: 'test.restricted-noop',
consumer: 'alerts',
})
)
.expect(200);
objectRemover.add(space.id, createdAlert.id, 'rule', 'alerting');
const response = await alertUtils
.getSnoozeRequest(createdAlert.id)
.send({ snooze_end_time: FUTURE_SNOOZE_TIME });
switch (scenario.id) {
case 'no_kibana_privileges at space1':
case 'space_1_all at space2':
expect(response.statusCode).to.eql(403);
expect(response.body).to.eql({
error: 'Forbidden',
message: getConsumerUnauthorizedErrorMessage(
'muteAll',
'test.restricted-noop',
'alerts'
),
statusCode: 403,
});
break;
case 'global_read at space1':
case 'space_1_all at space1':
case 'space_1_all_alerts_none_actions at space1':
expect(response.statusCode).to.eql(403);
expect(response.body).to.eql({
error: 'Forbidden',
message: getProducerUnauthorizedErrorMessage(
'muteAll',
'test.restricted-noop',
'alertsRestrictedFixture'
),
statusCode: 403,
});
break;
case 'superuser at space1':
case 'space_1_all_with_restricted_fixture at space1':
expect(response.statusCode).to.eql(204);
expect(response.body).to.eql('');
const { body: updatedAlert } = await supertestWithoutAuth
.get(`${getUrlPrefix(space.id)}/internal/alerting/rule/${createdAlert.id}`)
.set('kbn-xsrf', 'foo')
.auth(user.username, user.password)
.expect(200);
expect(updatedAlert.snooze_end_time).to.eql(FUTURE_SNOOZE_TIME);
expect(updatedAlert.mute_all).to.eql(false);
// Ensure AAD isn't broken
await checkAAD({
supertest,
spaceId: space.id,
type: 'alert',
id: createdAlert.id,
});
break;
default:
throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);
}
});
it('should handle snooze rule request appropriately when snoozeEndTime is -1', async () => {
const { body: createdAction } = await supertest
.post(`${getUrlPrefix(space.id)}/api/actions/connector`)
.set('kbn-xsrf', 'foo')
.send({
name: 'MY action',
connector_type_id: 'test.noop',
config: {},
secrets: {},
})
.expect(200);
const { body: createdAlert } = await supertest
.post(`${getUrlPrefix(space.id)}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(
getTestRuleData({
enabled: false,
actions: [
{
id: createdAction.id,
group: 'default',
params: {},
},
],
})
)
.expect(200);
objectRemover.add(space.id, createdAlert.id, 'rule', 'alerting');
const response = await alertUtils
.getSnoozeRequest(createdAlert.id)
.send({ snooze_end_time: -1 });
switch (scenario.id) {
case 'no_kibana_privileges at space1':
case 'space_1_all at space2':
case 'global_read at space1':
expect(response.statusCode).to.eql(403);
expect(response.body).to.eql({
error: 'Forbidden',
message: getConsumerUnauthorizedErrorMessage(
'muteAll',
'test.noop',
'alertsFixture'
),
statusCode: 403,
});
break;
case 'space_1_all_alerts_none_actions at space1':
expect(response.statusCode).to.eql(403);
expect(response.body).to.eql({
error: 'Forbidden',
message: `Unauthorized to execute actions`,
statusCode: 403,
});
break;
case 'superuser at space1':
case 'space_1_all at space1':
case 'space_1_all_with_restricted_fixture at space1':
expect(response.statusCode).to.eql(204);
expect(response.body).to.eql('');
const { body: updatedAlert } = await supertestWithoutAuth
.get(`${getUrlPrefix(space.id)}/internal/alerting/rule/${createdAlert.id}`)
.set('kbn-xsrf', 'foo')
.auth(user.username, user.password)
.expect(200);
expect(updatedAlert.snooze_end_time).to.eql(null);
expect(updatedAlert.mute_all).to.eql(true);
// Ensure AAD isn't broken
await checkAAD({
supertest,
spaceId: space.id,
type: 'alert',
id: createdAlert.id,
});
break;
default:
throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);
}
});
});
}
});
}

View file

@ -72,7 +72,9 @@ const findTestUtils = (
created_at: match.created_at,
updated_at: match.updated_at,
execution_status: match.execution_status,
...(describeType === 'internal' ? { monitoring: match.monitoring } : {}),
...(describeType === 'internal'
? { monitoring: match.monitoring, snooze_end_time: match.snooze_end_time }
: {}),
});
expect(Date.parse(match.created_at)).to.be.greaterThan(0);
expect(Date.parse(match.updated_at)).to.be.greaterThan(0);

View file

@ -54,7 +54,9 @@ const getTestUtils = (
created_at: response.body.created_at,
updated_at: response.body.updated_at,
execution_status: response.body.execution_status,
...(describeType === 'internal' ? { monitoring: response.body.monitoring } : {}),
...(describeType === 'internal'
? { monitoring: response.body.monitoring, snooze_end_time: response.body.snooze_end_time }
: {}),
});
expect(Date.parse(response.body.created_at)).to.be.greaterThan(0);
expect(Date.parse(response.body.updated_at)).to.be.greaterThan(0);

View file

@ -0,0 +1,135 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import expect from '@kbn/expect';
import { Spaces } from '../../scenarios';
import { FtrProviderContext } from '../../../common/ftr_provider_context';
import {
AlertUtils,
checkAAD,
getUrlPrefix,
getTestRuleData,
ObjectRemover,
} from '../../../common/lib';
const FUTURE_SNOOZE_TIME = '9999-12-31T06:00:00.000Z';
// eslint-disable-next-line import/no-default-export
export default function createSnoozeRuleTests({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const supertestWithoutAuth = getService('supertestWithoutAuth');
describe('snooze', () => {
const objectRemover = new ObjectRemover(supertest);
after(() => objectRemover.removeAll());
const alertUtils = new AlertUtils({ space: Spaces.space1, supertestWithoutAuth });
it('should handle snooze rule request appropriately', async () => {
const { body: createdAction } = await supertest
.post(`${getUrlPrefix(Spaces.space1.id)}}/api/actions/connector`)
.set('kbn-xsrf', 'foo')
.send({
name: 'MY action',
connector_type_id: 'test.noop',
config: {},
secrets: {},
})
.expect(200);
const { body: createdAlert } = await supertest
.post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(
getTestRuleData({
enabled: false,
actions: [
{
id: createdAction.id,
group: 'default',
params: {},
},
],
})
)
.expect(200);
objectRemover.add(Spaces.space1.id, createdAlert.id, 'rule', 'alerting');
const response = await alertUtils
.getSnoozeRequest(createdAlert.id)
.send({ snooze_end_time: FUTURE_SNOOZE_TIME });
expect(response.statusCode).to.eql(204);
expect(response.body).to.eql('');
const { body: updatedAlert } = await supertestWithoutAuth
.get(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${createdAlert.id}`)
.set('kbn-xsrf', 'foo')
.expect(200);
expect(updatedAlert.snooze_end_time).to.eql(FUTURE_SNOOZE_TIME);
expect(updatedAlert.mute_all).to.eql(false);
// Ensure AAD isn't broken
await checkAAD({
supertest,
spaceId: Spaces.space1.id,
type: 'alert',
id: createdAlert.id,
});
});
it('should handle snooze rule request appropriately when snoozeEndTime is -1', async () => {
const { body: createdAction } = await supertest
.post(`${getUrlPrefix(Spaces.space1.id)}}/api/actions/connector`)
.set('kbn-xsrf', 'foo')
.send({
name: 'MY action',
connector_type_id: 'test.noop',
config: {},
secrets: {},
})
.expect(200);
const { body: createdAlert } = await supertest
.post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(
getTestRuleData({
enabled: false,
actions: [
{
id: createdAction.id,
group: 'default',
params: {},
},
],
})
)
.expect(200);
objectRemover.add(Spaces.space1.id, createdAlert.id, 'rule', 'alerting');
const response = await alertUtils
.getSnoozeRequest(createdAlert.id)
.send({ snooze_end_time: -1 });
expect(response.statusCode).to.eql(204);
expect(response.body).to.eql('');
const { body: updatedAlert } = await supertestWithoutAuth
.get(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${createdAlert.id}`)
.set('kbn-xsrf', 'foo')
.expect(200);
expect(updatedAlert.snooze_end_time).to.eql(null);
expect(updatedAlert.mute_all).to.eql(true);
// Ensure AAD isn't broken
await checkAAD({
supertest,
spaceId: Spaces.space1.id,
type: 'alert',
id: createdAlert.id,
});
});
});
}