[RAM] Adds Bulk Edit API to rulesClient (#126904)

Addresses
- https://github.com/elastic/kibana/issues/124715

## Summary

- adds bulkEdit method to rulesClient
- adds multi_terms bucket aggregations to savedObjectClient
- adds createPointInTimeFinderAsInternalUser to encryptedSavedObjectClient
- adds alerting API for bulkEdit
```bash
curl --location --request POST 'http://localhost:5601/kbn/internal/alerting/rules/_bulk_edit' \
--header 'kbn-xsrf: reporting' \
--header 'Authorization: Basic ZWxhc3RpYzpjaGFuZ2VtZQ==' \
--header 'Content-Type: application/json' \
--data-raw '{
    "ids": ["4cb80374-b5c7-11ec-8f1e-adaa7d7d57e5"],
    "operations":  [{
        "operation": "add",
        "field": "tags",
        "value": ["foo"]
    }]
}'
```
### Checklist

Delete any items that are not applicable to this PR.

- [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios


## Release note

Adds new `bulkEdit` method to alerting rulesClient and internal _bulk_edit API, that allow bulk editing of rules.
This commit is contained in:
Vitalii Dmyterko 2022-05-11 19:27:52 +01:00 committed by GitHub
parent 1dc6216053
commit e3c47ecc46
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
50 changed files with 3887 additions and 462 deletions

View file

@ -19,6 +19,7 @@ import { sortOrderSchema } from './common_schemas';
* - nested
* - reverse_nested
* - terms
* - multi_terms
*
* Not fully supported:
* - filter
@ -37,7 +38,6 @@ import { sortOrderSchema } from './common_schemas';
* - global
* - ip_range
* - missing
* - multi_terms
* - parent
* - range
* - rare_terms
@ -63,6 +63,36 @@ const boolSchema = s.object({
}),
});
const orderSchema = s.oneOf([
sortOrderSchema,
s.recordOf(s.string(), sortOrderSchema),
s.arrayOf(s.recordOf(s.string(), sortOrderSchema)),
]);
const termsSchema = s.object({
field: s.maybe(s.string()),
collect_mode: s.maybe(s.string()),
exclude: s.maybe(s.oneOf([s.string(), s.arrayOf(s.string())])),
include: s.maybe(s.oneOf([s.string(), s.arrayOf(s.string())])),
execution_hint: s.maybe(s.string()),
missing: s.maybe(s.number()),
min_doc_count: s.maybe(s.number({ min: 1 })),
size: s.maybe(s.number()),
show_term_doc_count_error: s.maybe(s.boolean()),
order: s.maybe(orderSchema),
});
const multiTermsSchema = s.object({
terms: s.arrayOf(termsSchema),
size: s.maybe(s.number()),
shard_size: s.maybe(s.number()),
show_term_doc_count_error: s.maybe(s.boolean()),
min_doc_count: s.maybe(s.number()),
shard_min_doc_count: s.maybe(s.number()),
collect_mode: s.maybe(s.oneOf([s.literal('depth_first'), s.literal('breadth_first')])),
order: s.maybe(s.recordOf(s.string(), orderSchema)),
});
export const bucketAggsSchemas: Record<string, ObjectType> = {
date_range: s.object({
field: s.string(),
@ -104,22 +134,6 @@ export const bucketAggsSchemas: Record<string, ObjectType> = {
reverse_nested: s.object({
path: s.maybe(s.string()),
}),
terms: s.object({
field: s.maybe(s.string()),
collect_mode: s.maybe(s.string()),
exclude: s.maybe(s.oneOf([s.string(), s.arrayOf(s.string())])),
include: s.maybe(s.oneOf([s.string(), s.arrayOf(s.string())])),
execution_hint: s.maybe(s.string()),
missing: s.maybe(s.number()),
min_doc_count: s.maybe(s.number({ min: 1 })),
size: s.maybe(s.number()),
show_term_doc_count_error: s.maybe(s.boolean()),
order: s.maybe(
s.oneOf([
sortOrderSchema,
s.recordOf(s.string(), sortOrderSchema),
s.arrayOf(s.recordOf(s.string(), sortOrderSchema)),
])
),
}),
multi_terms: multiTermsSchema,
terms: termsSchema,
};

View file

@ -94,6 +94,28 @@ describe('validateAndConvertAggregations', () => {
});
});
it('validates multi_terms aggregations', () => {
expect(
validateAndConvertAggregations(
['foo'],
{
aggName: {
multi_terms: {
terms: [{ field: 'foo.attributes.description' }, { field: 'foo.attributes.bytes' }],
},
},
},
mockMappings
)
).toEqual({
aggName: {
multi_terms: {
terms: [{ field: 'foo.description' }, { field: 'foo.bytes' }],
},
},
});
});
it('validates a nested field in simple aggregations', () => {
expect(
validateAndConvertAggregations(

View file

@ -8,7 +8,7 @@
import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { ObjectType } from '@kbn/config-schema';
import { isPlainObject } from 'lodash';
import { isPlainObject, isArray } from 'lodash';
import { IndexMapping } from '../../../mappings';
import {
@ -181,11 +181,17 @@ const recursiveRewrite = (
const nestedContext = childContext(context, key);
const newKey = rewriteKey ? validateAndRewriteAttributePath(key, nestedContext) : key;
const newValue = rewriteValue
? validateAndRewriteAttributePath(value, nestedContext)
: isPlainObject(value)
? recursiveRewrite(value, nestedContext, [...parents, key])
: value;
let newValue = value;
if (rewriteValue) {
newValue = validateAndRewriteAttributePath(value, nestedContext);
} else if (isArray(value)) {
newValue = value.map((v) =>
isPlainObject(v) ? recursiveRewrite(v, nestedContext, parents) : v
);
} else if (isPlainObject(value)) {
newValue = recursiveRewrite(value, nestedContext, [...parents, key]);
}
return {
...memo,

View file

@ -47,6 +47,7 @@ export enum WriteOperations {
MuteAlert = 'muteAlert',
UnmuteAlert = 'unmuteAlert',
Snooze = 'snooze',
BulkEdit = 'bulkEdit',
Unsnooze = 'unsnooze',
}

View file

@ -31,7 +31,14 @@ export type {
} from './types';
export { DEFAULT_MAX_EPHEMERAL_ACTIONS_PER_ALERT } from './config';
export type { PluginSetupContract, PluginStartContract } from './plugin';
export type { FindResult } from './rules_client';
export type {
FindResult,
BulkEditOperation,
BulkEditError,
BulkEditOptions,
BulkEditOptionsFilter,
BulkEditOptionsIds,
} from './rules_client';
export type { PublicAlert as Alert } from './alert';
export { parseDuration } from './lib';
export { getEsErrorMessage } from './lib/errors';

View file

@ -0,0 +1,63 @@
/*
* 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 { loggingSystemMock, savedObjectsClientMock } from '@kbn/core/server/mocks';
import { bulkMarkApiKeysForInvalidation } from './bulk_mark_api_keys_for_invalidation';
describe('bulkMarkApiKeysForInvalidation', () => {
test('should call savedObjectsClient bulkCreate with the proper params', async () => {
const unsecuredSavedObjectsClient = savedObjectsClientMock.create();
unsecuredSavedObjectsClient.bulkCreate.mockResolvedValueOnce({ saved_objects: [] });
await bulkMarkApiKeysForInvalidation(
{ apiKeys: [Buffer.from('123').toString('base64'), Buffer.from('456').toString('base64')] },
loggingSystemMock.create().get(),
unsecuredSavedObjectsClient
);
const bulkCreateCallMock = unsecuredSavedObjectsClient.bulkCreate.mock.calls[0];
const savedObjects = bulkCreateCallMock[0];
expect(unsecuredSavedObjectsClient.bulkCreate).toHaveBeenCalledTimes(1);
expect(bulkCreateCallMock).toHaveLength(1);
expect(savedObjects).toHaveLength(2);
expect(savedObjects[0]).toHaveProperty('type', 'api_key_pending_invalidation');
expect(savedObjects[0]).toHaveProperty('attributes.apiKeyId', '123');
expect(savedObjects[0]).toHaveProperty('attributes.createdAt', expect.any(String));
expect(savedObjects[1]).toHaveProperty('type', 'api_key_pending_invalidation');
expect(savedObjects[1]).toHaveProperty('attributes.apiKeyId', '456');
expect(savedObjects[1]).toHaveProperty('attributes.createdAt', expect.any(String));
});
test('should log the proper error when savedObjectsClient create failed', async () => {
const logger = loggingSystemMock.create().get();
const unsecuredSavedObjectsClient = savedObjectsClientMock.create();
unsecuredSavedObjectsClient.bulkCreate.mockRejectedValueOnce(new Error('Fail'));
await bulkMarkApiKeysForInvalidation(
{ apiKeys: [Buffer.from('123').toString('base64'), Buffer.from('456').toString('base64')] },
logger,
unsecuredSavedObjectsClient
);
expect(logger.error).toHaveBeenCalledWith(
'Failed to bulk mark list of API keys ["MTIz", "NDU2"] for invalidation: Fail'
);
});
test('should not call savedObjectsClient bulkCreate if list of apiKeys empty', async () => {
const unsecuredSavedObjectsClient = savedObjectsClientMock.create();
unsecuredSavedObjectsClient.bulkCreate.mockResolvedValueOnce({ saved_objects: [] });
await bulkMarkApiKeysForInvalidation(
{ apiKeys: [] },
loggingSystemMock.create().get(),
unsecuredSavedObjectsClient
);
expect(unsecuredSavedObjectsClient.bulkCreate).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,39 @@
/*
* 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 { Logger, SavedObjectsClientContract } from '@kbn/core/server';
export const bulkMarkApiKeysForInvalidation = async (
{ apiKeys }: { apiKeys: string[] },
logger: Logger,
savedObjectsClient: SavedObjectsClientContract
): Promise<void> => {
if (apiKeys.length === 0) {
return;
}
try {
const apiKeyIds = apiKeys.map(
(apiKey) => Buffer.from(apiKey, 'base64').toString().split(':')[0]
);
await savedObjectsClient.bulkCreate(
apiKeyIds.map((apiKeyId) => ({
attributes: {
apiKeyId,
createdAt: new Date().toISOString(),
},
type: 'api_key_pending_invalidation',
}))
);
} catch (e) {
logger.error(
`Failed to bulk mark list of API keys [${apiKeys
.map((key) => `"${key}"`)
.join(', ')}] for invalidation: ${e.message}`
);
}
};

View file

@ -1,48 +0,0 @@
/*
* 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 { loggingSystemMock, savedObjectsClientMock } from '@kbn/core/server/mocks';
import { markApiKeyForInvalidation } from './mark_api_key_for_invalidation';
describe('markApiKeyForInvalidation', () => {
test('should call savedObjectsClient create with the proper params', async () => {
const unsecuredSavedObjectsClient = savedObjectsClientMock.create();
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
id: '1',
type: 'api_key_pending_invalidation',
attributes: {
apiKeyId: '123',
createdAt: '2019-02-12T21:01:22.479Z',
},
references: [],
});
await markApiKeyForInvalidation(
{ apiKey: Buffer.from('123:abc').toString('base64') },
loggingSystemMock.create().get(),
unsecuredSavedObjectsClient
);
expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(1);
expect(unsecuredSavedObjectsClient.create.mock.calls[0]).toHaveLength(2);
expect(unsecuredSavedObjectsClient.create.mock.calls[0][0]).toEqual(
'api_key_pending_invalidation'
);
});
test('should log the proper error when savedObjectsClient create failed', async () => {
const logger = loggingSystemMock.create().get();
const unsecuredSavedObjectsClient = savedObjectsClientMock.create();
unsecuredSavedObjectsClient.create.mockRejectedValueOnce(new Error('Fail'));
await markApiKeyForInvalidation(
{ apiKey: Buffer.from('123').toString('base64') },
logger,
unsecuredSavedObjectsClient
);
expect(logger.error).toHaveBeenCalledWith(
'Failed to mark for API key [id="MTIz"] for invalidation: Fail'
);
});
});

View file

@ -1,27 +0,0 @@
/*
* 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 { Logger, SavedObjectsClientContract } from '@kbn/core/server';
export const markApiKeyForInvalidation = async (
{ apiKey }: { apiKey: string | null },
logger: Logger,
savedObjectsClient: SavedObjectsClientContract
): Promise<void> => {
if (!apiKey) {
return;
}
try {
const apiKeyId = Buffer.from(apiKey, 'base64').toString().split(':')[0];
await savedObjectsClient.create('api_key_pending_invalidation', {
apiKeyId,
createdAt: new Date().toISOString(),
});
} catch (e) {
logger.error(`Failed to mark for API key [id="${apiKey}"] for invalidation: ${e.message}`);
}
};

View file

@ -0,0 +1,71 @@
/*
* 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 { convertRuleIdsToKueryNode } from './convert_rule_ids_to_kuery_node';
describe('convertRuleIdsToKueryNode', () => {
test('should convert ids correctly', () => {
expect(convertRuleIdsToKueryNode(['1'])).toEqual({
arguments: [
{ type: 'literal', value: 'alert.id' },
{ type: 'literal', value: 'alert:1' },
{ type: 'literal', value: false },
],
function: 'is',
type: 'function',
});
});
test('should convert multiple ids correctly', () => {
expect(convertRuleIdsToKueryNode(['1', '22'])).toEqual({
arguments: [
{
arguments: [
{
type: 'literal',
value: 'alert.id',
},
{
type: 'literal',
value: 'alert:1',
},
{
type: 'literal',
value: false,
},
],
function: 'is',
type: 'function',
},
{
arguments: [
{
type: 'literal',
value: 'alert.id',
},
{
type: 'literal',
value: 'alert:22',
},
{
type: 'literal',
value: false,
},
],
function: 'is',
type: 'function',
},
],
function: 'or',
type: 'function',
});
});
test('should convert empty ids array correctly', () => {
expect(convertRuleIdsToKueryNode([])).toEqual(undefined);
});
});

View file

@ -0,0 +1,15 @@
/*
* 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 { nodeBuilder } from '@kbn/es-query';
/**
* This utility converts array of rule ids into qNode filter
*/
export const convertRuleIdsToKueryNode = (ids: string[]) =>
nodeBuilder.or(ids.map((ruleId) => nodeBuilder.is('alert.id', `alert:${ruleId}`)));

View file

@ -9,6 +9,7 @@ export { parseDuration, validateDurationSchema } from '../../common/parse_durati
export type { ILicenseState } from './license_state';
export { LicenseState } from './license_state';
export { validateRuleTypeParams } from './validate_rule_type_params';
export { validateMutatedRuleTypeParams } from './validate_mutated_rule_type_params';
export { getRuleNotifyWhenType } from './get_rule_notify_when_type';
export { verifyApiAccess } from './license_api_access';
export { ErrorWithReason, getReasonFromError, isErrorWithReason } from './error_with_reason';
@ -26,3 +27,4 @@ export {
} from './rule_execution_status';
export { getRecoveredAlerts } from './get_recovered_alerts';
export { createWrappedScopedClusterClientFactory } from './wrap_scoped_cluster_client';
export { convertRuleIdsToKueryNode } from './convert_rule_ids_to_kuery_node';

View file

@ -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 Boom from '@hapi/boom';
import { RuleTypeParams, RuleTypeParamsValidator } from '../types';
export function validateMutatedRuleTypeParams<Params extends RuleTypeParams>(
mutatedParams: Params,
origParams?: Params,
validator?: RuleTypeParamsValidator<Params>
): Params {
if (!validator) {
return mutatedParams;
}
try {
if (validator.validateMutatedParams) {
return validator.validateMutatedParams(mutatedParams, origParams);
}
return mutatedParams;
} catch (err) {
throw Boom.badRequest(`Mutated params invalid: ${err.message}`);
}
}

View file

@ -0,0 +1,188 @@
/*
* 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 '@kbn/core/server/mocks';
import { bulkEditInternalRulesRoute } from './bulk_edit_rules';
import { licenseStateMock } from '../lib/license_state.mock';
import { verifyApiAccess } from '../lib/license_api_access';
import { RuleTypeDisabledError } from '../lib/errors/rule_type_disabled';
import { mockHandlerArguments } from './_mock_handler_arguments';
import { rulesClientMock } from '../rules_client.mock';
import { SanitizedRule } from '../types';
const rulesClient = rulesClientMock.create();
jest.mock('../lib/license_api_access', () => ({
verifyApiAccess: jest.fn(),
}));
beforeEach(() => {
jest.resetAllMocks();
});
describe('bulkEditInternalRulesRoute', () => {
const mockedAlert: SanitizedRule<{}> = {
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'),
},
};
const mockedAlerts: Array<SanitizedRule<{}>> = [mockedAlert];
const bulkEditRequest = {
filter: '',
operations: [
{
action: 'add',
field: 'tags',
value: ['alerting-1'],
},
],
};
const bulkEditResult = { rules: mockedAlerts, errors: [], total: 1 };
it('bulk edits rules with tags action', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
bulkEditInternalRulesRoute(router, licenseState);
const [config, handler] = router.post.mock.calls[0];
expect(config.path).toBe('/internal/alerting/rules/_bulk_edit');
rulesClient.bulkEdit.mockResolvedValueOnce(bulkEditResult);
const [context, req, res] = mockHandlerArguments(
{ rulesClient },
{
body: bulkEditRequest,
},
['ok']
);
expect(await handler(context, req, res)).toEqual({
body: {
total: 1,
errors: [],
rules: [
expect.objectContaining({
id: '1',
name: 'abc',
tags: ['foo'],
actions: [
{
group: 'default',
id: '2',
connector_type_id: 'test',
params: {
foo: true,
},
},
],
}),
],
},
});
expect(rulesClient.bulkEdit).toHaveBeenCalledTimes(1);
expect(rulesClient.bulkEdit.mock.calls[0]).toEqual([bulkEditRequest]);
expect(res.ok).toHaveBeenCalled();
});
it('ensures the license allows bulk editing rules', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
rulesClient.bulkEdit.mockResolvedValueOnce(bulkEditResult);
bulkEditInternalRulesRoute(router, licenseState);
const [, handler] = router.post.mock.calls[0];
const [context, req, res] = mockHandlerArguments(
{ rulesClient },
{
body: bulkEditRequest,
}
);
await handler(context, req, res);
expect(verifyApiAccess).toHaveBeenCalledWith(licenseState);
});
it('ensures the license check prevents bulk editing rules', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
(verifyApiAccess as jest.Mock).mockImplementation(() => {
throw new Error('Failure');
});
bulkEditInternalRulesRoute(router, licenseState);
const [, handler] = router.post.mock.calls[0];
const [context, req, res] = mockHandlerArguments(
{ rulesClient },
{
body: bulkEditRequest,
}
);
expect(handler(context, req, res)).rejects.toMatchInlineSnapshot(`[Error: Failure]`);
});
it('ensures the rule type gets validated for the license', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
bulkEditInternalRulesRoute(router, licenseState);
const [, handler] = router.post.mock.calls[0];
rulesClient.bulkEdit.mockRejectedValue(new RuleTypeDisabledError('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,96 @@
/*
* 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 { schema } from '@kbn/config-schema';
import { IRouter } from '@kbn/core/server';
import { ILicenseState, RuleTypeDisabledError } from '../lib';
import { verifyAccessAndContext, rewriteRule, handleDisabledApiKeysError } from './lib';
import { AlertingRequestHandlerContext, INTERNAL_BASE_ALERTING_API_PATH } from '../types';
const ruleActionSchema = schema.object({
group: schema.string(),
id: schema.string(),
params: schema.recordOf(schema.string(), schema.any(), { defaultValue: {} }),
});
const operationsSchema = schema.arrayOf(
schema.oneOf([
schema.object({
operation: schema.oneOf([
schema.literal('add'),
schema.literal('delete'),
schema.literal('set'),
]),
field: schema.literal('tags'),
value: schema.arrayOf(schema.string()),
}),
schema.object({
operation: schema.oneOf([schema.literal('add'), schema.literal('set')]),
field: schema.literal('actions'),
value: schema.arrayOf(ruleActionSchema),
}),
]),
{ minSize: 1 }
);
const bodySchema = schema.object({
filter: schema.maybe(schema.string()),
ids: schema.maybe(schema.arrayOf(schema.string(), { minSize: 1 })),
operations: operationsSchema,
});
interface BuildBulkEditRulesRouteParams {
licenseState: ILicenseState;
path: string;
router: IRouter<AlertingRequestHandlerContext>;
}
const buildBulkEditRulesRoute = ({ licenseState, path, router }: BuildBulkEditRulesRouteParams) => {
router.post(
{
path,
validate: {
body: bodySchema,
},
},
handleDisabledApiKeysError(
router.handleLegacyErrors(
verifyAccessAndContext(licenseState, async function (context, req, res) {
const rulesClient = (await context.alerting).getRulesClient();
const { filter, operations, ids } = req.body;
try {
const bulkEditResults = await rulesClient.bulkEdit({
filter,
ids: ids as string[],
operations,
});
return res.ok({
body: { ...bulkEditResults, rules: bulkEditResults.rules.map(rewriteRule) },
});
} catch (e) {
if (e instanceof RuleTypeDisabledError) {
return e.sendResponse(res);
}
throw e;
}
})
)
)
);
};
export const bulkEditInternalRulesRoute = (
router: IRouter<AlertingRequestHandlerContext>,
licenseState: ILicenseState
) =>
buildBulkEditRulesRoute({
licenseState,
path: `${INTERNAL_BASE_ALERTING_API_PATH}/rules/_bulk_edit`,
router,
});

View file

@ -5,13 +5,17 @@
* 2.0.
*/
import { omit } from 'lodash';
import { IRouter } from '@kbn/core/server';
import { UsageCounter } from '@kbn/usage-collection-plugin/server';
import { schema } from '@kbn/config-schema';
import { ILicenseState } from '../lib';
import { FindOptions, FindResult } from '../rules_client';
import { RewriteRequestCase, RewriteResponseCase, verifyAccessAndContext } from './lib';
import {
RewriteRequestCase,
RewriteResponseCase,
verifyAccessAndContext,
rewriteRule,
} from './lib';
import {
RuleTypeParams,
AlertingRequestHandlerContext,
@ -70,49 +74,7 @@ const rewriteBodyRes: RewriteResponseCase<FindResult<RuleTypeParams>> = ({
return {
...restOfResult,
per_page: perPage,
data: data.map(
({
alertTypeId,
createdBy,
updatedBy,
createdAt,
updatedAt,
apiKeyOwner,
notifyWhen,
muteAll,
mutedInstanceIds,
executionStatus,
actions,
scheduledTaskId,
snoozeEndTime,
...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,
// 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,
last_duration: executionStatus.lastDuration,
},
actions: actions.map(({ group, id, actionTypeId, params }) => ({
group,
id,
params,
connector_type_id: actionTypeId,
})),
})
),
data: data.map(rewriteRule),
};
};

View file

@ -30,6 +30,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 { bulkEditInternalRulesRoute } from './bulk_edit_rules';
import { snoozeRuleRoute } from './snooze_rule';
import { unsnoozeRuleRoute } from './unsnooze_rule';
@ -65,6 +66,7 @@ export function defineRoutes(opts: RouteOptions) {
unmuteAllRuleRoute(router, licenseState);
unmuteAlertRoute(router, licenseState);
updateRuleApiKeyRoute(router, licenseState);
bulkEditInternalRulesRoute(router, licenseState);
snoozeRuleRoute(router, licenseState);
unsnoozeRuleRoute(router, licenseState);
}

View file

@ -18,3 +18,4 @@ export type {
} from './rewrite_request_case';
export { verifyAccessAndContext } from './verify_access_and_context';
export { countUsageOfPredefinedIds } from './count_usage_of_predefined_ids';
export { rewriteRule } from './rewrite_rule';

View file

@ -0,0 +1,51 @@
/*
* 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 { RuleTypeParams, SanitizedRule } from '../../types';
export const rewriteRule = ({
alertTypeId,
createdBy,
updatedBy,
createdAt,
updatedAt,
apiKeyOwner,
notifyWhen,
muteAll,
mutedInstanceIds,
executionStatus,
actions,
scheduledTaskId,
snoozeEndTime,
...rest
}: SanitizedRule<RuleTypeParams>) => ({
...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,
// 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,
last_duration: executionStatus.lastDuration,
},
actions: actions.map(({ group, id, actionTypeId, params }) => ({
group,
id,
params,
connector_type_id: actionTypeId,
})),
});

View file

@ -32,6 +32,7 @@ const createRulesClientMock = () => {
getAlertSummary: jest.fn(),
getExecutionLogForRule: jest.fn(),
getSpaceId: jest.fn(),
bulkEdit: jest.fn(),
snooze: jest.fn(),
unsnooze: jest.fn(),
};

View file

@ -23,6 +23,7 @@ export enum RuleAuditAction {
MUTE_ALERT = 'rule_alert_mute',
UNMUTE_ALERT = 'rule_alert_unmute',
AGGREGATE = 'rule_aggregate',
BULK_EDIT = 'rule_bulk_edit',
GET_EXECUTION_LOG = 'rule_get_execution_log',
SNOOZE = 'rule_snooze',
UNSNOOZE = 'rule_unsnooze',
@ -35,6 +36,7 @@ const eventVerbs: Record<RuleAuditAction, VerbsTuple> = {
rule_get: ['access', 'accessing', 'accessed'],
rule_resolve: ['access', 'accessing', 'accessed'],
rule_update: ['update', 'updating', 'updated'],
rule_bulk_edit: ['update', 'updating', 'updated'],
rule_update_api_key: ['update API key of', 'updating API key of', 'updated API key of'],
rule_enable: ['enable', 'enabling', 'enabled'],
rule_disable: ['disable', 'disabling', 'disabled'],
@ -59,6 +61,7 @@ const eventTypes: Record<RuleAuditAction, EcsEventType> = {
rule_get: 'access',
rule_resolve: 'access',
rule_update: 'change',
rule_bulk_edit: 'change',
rule_update_api_key: 'change',
rule_enable: 'change',
rule_disable: 'change',

View file

@ -0,0 +1,171 @@
/*
* 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 { applyBulkEditOperation } from './apply_bulk_edit_operation';
import type { Rule } from '../../types';
describe('applyBulkEditOperation', () => {
describe('tags operations', () => {
test('should add tag', () => {
const ruleMock: Partial<Rule> = {
tags: ['tag-1', 'tag-2'],
};
expect(
applyBulkEditOperation(
{
field: 'tags',
value: ['add-tag'],
operation: 'add',
},
ruleMock
)
).toHaveProperty('tags', ['tag-1', 'tag-2', 'add-tag']);
});
test('should add multiple tags', () => {
const ruleMock: Partial<Rule> = {
tags: ['tag-1', 'tag-2'],
};
expect(
applyBulkEditOperation(
{
field: 'tags',
value: ['add-tag-1', 'add-tag-2'],
operation: 'add',
},
ruleMock
)
).toHaveProperty('tags', ['tag-1', 'tag-2', 'add-tag-1', 'add-tag-2']);
});
test('should not have duplicated tags when added existed ones', () => {
const ruleMock: Partial<Rule> = {
tags: ['tag-1', 'tag-2'],
};
expect(
applyBulkEditOperation(
{
field: 'tags',
value: ['tag-1', 'tag-3'],
operation: 'add',
},
ruleMock
)
).toHaveProperty('tags', ['tag-1', 'tag-2', 'tag-3']);
});
test('should delete tag', () => {
const ruleMock: Partial<Rule> = {
tags: ['tag-1', 'tag-2'],
};
expect(
applyBulkEditOperation(
{
field: 'tags',
value: ['tag-1'],
operation: 'delete',
},
ruleMock
)
).toHaveProperty('tags', ['tag-2']);
});
test('should delete multiple tags', () => {
const ruleMock: Partial<Rule> = {
tags: ['tag-1', 'tag-2'],
};
expect(
applyBulkEditOperation(
{
field: 'tags',
value: ['tag-1', 'tag-2'],
operation: 'delete',
},
ruleMock
)
).toHaveProperty('tags', []);
});
test('should rewrite tags', () => {
const ruleMock: Partial<Rule> = {
tags: ['tag-1', 'tag-2'],
};
expect(
applyBulkEditOperation(
{
field: 'tags',
value: ['rewrite-tag'],
operation: 'set',
},
ruleMock
)
).toHaveProperty('tags', ['rewrite-tag']);
});
});
describe('actions operations', () => {
test('should add actions', () => {
const ruleMock = {
actions: [{ id: 'mock-action-id', group: 'default', params: {} }],
};
expect(
applyBulkEditOperation(
{
field: 'actions',
value: [
{ id: 'mock-add-action-id-1', group: 'default', params: {} },
{ id: 'mock-add-action-id-2', group: 'default', params: {} },
],
operation: 'add',
},
ruleMock
)
).toHaveProperty('actions', [
{ id: 'mock-action-id', group: 'default', params: {} },
{ id: 'mock-add-action-id-1', group: 'default', params: {} },
{ id: 'mock-add-action-id-2', group: 'default', params: {} },
]);
});
test('should add action with different params and same id', () => {
const ruleMock = {
actions: [{ id: 'mock-action-id', group: 'default', params: { test: 1 } }],
};
expect(
applyBulkEditOperation(
{
field: 'actions',
value: [{ id: 'mock-action-id', group: 'default', params: { test: 2 } }],
operation: 'add',
},
ruleMock
)
).toHaveProperty('actions', [
{ id: 'mock-action-id', group: 'default', params: { test: 1 } },
{ id: 'mock-action-id', group: 'default', params: { test: 2 } },
]);
});
test('should rewrite actions', () => {
const ruleMock = {
actions: [{ id: 'mock-action-id', group: 'default', params: {} }],
};
expect(
applyBulkEditOperation(
{
field: 'actions',
value: [{ id: 'mock-rewrite-action-id-1', group: 'default', params: {} }],
operation: 'set',
},
ruleMock
)
).toHaveProperty('actions', [
{ id: 'mock-rewrite-action-id-1', group: 'default', params: {} },
]);
});
});
});

View file

@ -0,0 +1,53 @@
/*
* 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 { set, get } from 'lodash';
import type { BulkEditOperation, BulkEditFields } from '../rules_client';
// defining an union type that will passed directly to generic function as a workaround for the issue similar to
// https://github.com/microsoft/TypeScript/issues/29479
type AddItemToArray =
| Extract<BulkEditOperation, { field: Extract<BulkEditFields, 'tags'> }>['value'][number]
| Extract<BulkEditOperation, { field: Extract<BulkEditFields, 'actions'> }>['value'][number];
/**
* this method takes BulkEdit operation and applies it to rule, by mutating it
* @param operation BulkEditOperation
* @param rule object rule to update
* @returns modified rule
*/
export const applyBulkEditOperation = <R extends object>(operation: BulkEditOperation, rule: R) => {
const addItemsToArray = <T>(arr: T[], items: T[]): T[] => Array.from(new Set([...arr, ...items]));
const deleteItemsFromArray = <T>(arr: T[], items: T[]): T[] => {
const itemsSet = new Set(items);
return arr.filter((item) => !itemsSet.has(item));
};
switch (operation.operation) {
case 'set':
set(rule, operation.field, operation.value);
break;
case 'add':
set(
rule,
operation.field,
addItemsToArray<AddItemToArray>(get(rule, operation.field) ?? [], operation.value)
);
break;
case 'delete':
set(
rule,
operation.field,
deleteItemsFromArray(get(rule, operation.field) ?? [], operation.value)
);
break;
}
return rule;
};

View file

@ -7,3 +7,5 @@
export { mapSortField } from './map_sort_field';
export { validateOperationOnAttributes } from './validate_attributes';
export { retryIfBulkEditConflicts } from './retry_if_bulk_edit_conflicts';
export { applyBulkEditOperation } from './apply_bulk_edit_operation';

View file

@ -0,0 +1,146 @@
/*
* 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 { KueryNode } from '@kbn/es-query';
import {
retryIfBulkEditConflicts,
RetryForConflictsAttempts,
} from './retry_if_bulk_edit_conflicts';
import { loggingSystemMock } from '@kbn/core/server/mocks';
const mockFilter: KueryNode = {
type: 'function',
value: 'mock',
};
const mockOperationName = 'conflict-retryable-operation';
const mockLogger = loggingSystemMock.create().get();
const mockSuccessfulResult = {
apiKeysToInvalidate: [],
rules: [
{ id: '1', type: 'alert', attributes: {} },
{ id: '2', type: 'alert', attributes: { name: 'Test rule 2' } },
],
resultSavedObjects: [
{ id: '1', type: 'alert', attributes: {}, references: [] },
{ id: '2', type: 'alert', attributes: { name: 'Test rule 2' }, references: [] },
],
errors: [],
};
async function OperationSuccessful() {
return mockSuccessfulResult;
}
const conflictOperationMock = jest.fn();
function getOperationConflictsTimes(times: number) {
return async function OperationConflictsTimes() {
conflictOperationMock();
times--;
if (times >= 0) {
return {
...mockSuccessfulResult,
resultSavedObjects: [
{ id: '1', type: 'alert', attributes: {}, references: [] },
{
id: '2',
type: 'alert',
attributes: {},
references: [],
error: {
statusCode: 409,
error: 'Conflict',
message: 'Saved object [alert/2] conflict',
},
},
],
};
}
return mockSuccessfulResult;
};
}
describe('retryIfBulkEditConflicts', () => {
beforeEach(() => {
jest.resetAllMocks();
});
test('should work when operation is a success', async () => {
const result = await retryIfBulkEditConflicts(
mockLogger,
mockOperationName,
OperationSuccessful,
mockFilter
);
expect(result).toEqual({
apiKeysToInvalidate: [],
errors: [],
results: [
{
attributes: {},
id: '1',
references: [],
type: 'alert',
},
{
attributes: {
name: 'Test rule 2',
},
id: '2',
references: [],
type: 'alert',
},
],
});
});
test(`should throw error when operation fails`, async () => {
await expect(
retryIfBulkEditConflicts(
mockLogger,
mockOperationName,
async () => {
throw Error('Test failure');
},
mockFilter
)
).rejects.toThrowError('Test failure');
});
test(`should return conflict errors when number of retries exceeds ${RetryForConflictsAttempts}`, async () => {
const result = await retryIfBulkEditConflicts(
mockLogger,
mockOperationName,
getOperationConflictsTimes(RetryForConflictsAttempts + 1),
mockFilter
);
expect(result.errors).toEqual([
{
message: 'Saved object [alert/2] conflict',
rule: {
id: '2',
name: 'Test rule 2',
},
},
]);
expect(mockLogger.warn).toBeCalledWith(`${mockOperationName} conflicts, exceeded retries`);
});
for (let i = 1; i <= RetryForConflictsAttempts; i++) {
test(`should work when operation conflicts ${i} times`, async () => {
const result = await retryIfBulkEditConflicts(
mockLogger,
mockOperationName,
getOperationConflictsTimes(i),
mockFilter
);
expect(result).toBe(result);
});
}
});

View file

@ -0,0 +1,166 @@
/*
* 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 pMap from 'p-map';
import { chunk } from 'lodash';
import { KueryNode } from '@kbn/es-query';
import { Logger, SavedObjectsBulkUpdateObject, SavedObjectsUpdateResponse } from '@kbn/core/server';
import { convertRuleIdsToKueryNode } from '../../lib';
import { BulkEditError } from '../rules_client';
import { RawRule } from '../../types';
// number of times to retry when conflicts occur
export const RetryForConflictsAttempts = 2;
// milliseconds to wait before retrying when conflicts occur
// note: we considered making this random, to help avoid a stampede, but
// with 1 retry it probably doesn't matter, and adding randomness could
// make it harder to diagnose issues
const RetryForConflictsDelay = 250;
// max number of failed SO ids in one retry filter
const MaxIdsNumberInRetryFilter = 1000;
type BulkEditOperation = (filter: KueryNode | null) => Promise<{
apiKeysToInvalidate: string[];
rules: Array<SavedObjectsBulkUpdateObject<RawRule>>;
resultSavedObjects: Array<SavedObjectsUpdateResponse<RawRule>>;
errors: BulkEditError[];
}>;
interface ReturnRetry {
apiKeysToInvalidate: string[];
results: Array<SavedObjectsUpdateResponse<RawRule>>;
errors: BulkEditError[];
}
/**
* Retries BulkEdit requests
* If in response are presents conflicted savedObjects(409 statusCode), this util constructs filter with failed SO ids and retries bulkEdit operation until
* all SO updated or number of retries exceeded
* @param logger
* @param name
* @param bulkEditOperation
* @param filter - KueryNode filter
* @param retries - number of retries left
* @param accApiKeysToInvalidate - accumulated apiKeys that need to be invalidated
* @param accResults - accumulated updated savedObjects
* @param accErrors - accumulated conflict errors
* @returns Promise<ReturnRetry>
*/
export const retryIfBulkEditConflicts = async (
logger: Logger,
name: string,
bulkEditOperation: BulkEditOperation,
filter: KueryNode | null,
retries: number = RetryForConflictsAttempts,
accApiKeysToInvalidate: string[] = [],
accResults: Array<SavedObjectsUpdateResponse<RawRule>> = [],
accErrors: BulkEditError[] = []
): Promise<ReturnRetry> => {
// run the operation, return if no errors or throw if not a conflict error
try {
const {
apiKeysToInvalidate: localApiKeysToInvalidate,
resultSavedObjects,
errors: localErrors,
rules: localRules,
} = await bulkEditOperation(filter);
const conflictErrorMap = resultSavedObjects.reduce<Map<string, { message: string }>>(
(acc, item) => {
if (item.type === 'alert' && item?.error?.statusCode === 409) {
return acc.set(item.id, { message: item.error.message });
}
return acc;
},
new Map()
);
const results = [...accResults, ...resultSavedObjects.filter((res) => res.error === undefined)];
const apiKeysToInvalidate = [...accApiKeysToInvalidate, ...localApiKeysToInvalidate];
const errors = [...accErrors, ...localErrors];
if (conflictErrorMap.size === 0) {
return {
apiKeysToInvalidate,
results,
errors,
};
}
if (retries <= 0) {
logger.warn(`${name} conflicts, exceeded retries`);
const conflictErrors = localRules
.filter((obj) => conflictErrorMap.has(obj.id))
.map((obj) => ({
message: conflictErrorMap.get(obj.id)?.message ?? 'n/a',
rule: {
id: obj.id,
name: obj.attributes?.name ?? 'n/a',
},
}));
return {
apiKeysToInvalidate,
results,
errors: [...errors, ...conflictErrors],
};
}
const ids = Array.from(conflictErrorMap.keys());
logger.debug(`${name} conflicts, retrying ..., ${ids.length} saved objects conflicted`);
// delay before retry
await waitBeforeNextRetry(retries);
// here, we construct filter query with ids. But, due to a fact that number of conflicted saved objects can exceed few thousands we can encounter following error:
// "all shards failed: search_phase_execution_exception: [query_shard_exception] Reason: failed to create query: maxClauseCount is set to 2621"
// That's why we chunk processing ids into pieces by size equals to MaxIdsNumberInRetryFilter
return (
await pMap(
chunk(ids, MaxIdsNumberInRetryFilter),
async (queryIds) =>
retryIfBulkEditConflicts(
logger,
name,
bulkEditOperation,
convertRuleIdsToKueryNode(queryIds),
retries - 1,
apiKeysToInvalidate,
results,
errors
),
{
concurrency: 1,
}
)
).reduce<ReturnRetry>(
(acc, item) => {
return {
results: [...acc.results, ...item.results],
apiKeysToInvalidate: [...acc.apiKeysToInvalidate, ...item.apiKeysToInvalidate],
errors: [...acc.errors, ...item.errors],
};
},
{ results: [], apiKeysToInvalidate: [], errors: [] }
);
} catch (err) {
throw err;
}
};
// exponential delay before retry with adding random delay
async function waitBeforeNextRetry(retries: number): Promise<void> {
const exponentialDelayMultiplier = 1 + (RetryForConflictsAttempts - retries) ** 2;
const randomDelayMs = Math.floor(Math.random() * 100);
await new Promise((resolve) =>
setTimeout(resolve, RetryForConflictsDelay * exponentialDelayMultiplier + randomDelayMs)
);
}

View file

@ -6,8 +6,9 @@
*/
import Semver from 'semver';
import pMap from 'p-map';
import Boom from '@hapi/boom';
import { omit, isEqual, map, uniq, pick, truncate, trim, mapValues } from 'lodash';
import { omit, isEqual, map, uniq, pick, truncate, trim, mapValues, cloneDeep } from 'lodash';
import { i18n } from '@kbn/i18n';
import { fromKueryExpression, KueryNode, nodeBuilder } from '@kbn/es-query';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
@ -19,6 +20,8 @@ import {
PluginInitializerContext,
SavedObjectsUtils,
SavedObjectAttributes,
SavedObjectsBulkUpdateObject,
SavedObjectsUpdateResponse,
} from '@kbn/core/server';
import { ActionsClient, ActionsAuthorization } from '@kbn/actions-plugin/server';
import {
@ -53,7 +56,13 @@ import {
PartialRuleWithLegacyId,
RawAlertInstance as RawAlert,
} from '../types';
import { validateRuleTypeParams, ruleExecutionStatusFromRaw, getRuleNotifyWhenType } from '../lib';
import {
validateRuleTypeParams,
ruleExecutionStatusFromRaw,
getRuleNotifyWhenType,
validateMutatedRuleTypeParams,
convertRuleIdsToKueryNode,
} from '../lib';
import { taskInstanceToAlertTaskInstance } from '../task_runner/alert_task_instance';
import { RegistryRuleType, UntypedNormalizedRuleType } from '../rule_type_registry';
import {
@ -69,9 +78,14 @@ import { alertSummaryFromEventLog } from '../lib/alert_summary_from_event_log';
import { parseDuration } from '../../common/parse_duration';
import { retryIfConflicts } from '../lib/retry_if_conflicts';
import { partiallyUpdateAlert } from '../saved_objects';
import { markApiKeyForInvalidation } from '../invalidate_pending_api_keys/mark_api_key_for_invalidation';
import { bulkMarkApiKeysForInvalidation } from '../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation';
import { ruleAuditEvent, RuleAuditAction } from './audit_events';
import { mapSortField, validateOperationOnAttributes } from './lib';
import {
mapSortField,
validateOperationOnAttributes,
retryIfBulkEditConflicts,
applyBulkEditOperation,
} from './lib';
import { getRuleExecutionStatusPending } from '../lib/rule_execution_status';
import { Alert } from '../alert';
import { EVENT_LOG_ACTIONS } from '../plugin';
@ -141,6 +155,15 @@ export interface RuleAggregation {
};
}
export interface RuleBulkEditAggregation {
alertTypeId: {
buckets: Array<{
key: string[];
doc_count: number;
}>;
};
}
export interface ConstructorOptions {
logger: Logger;
taskManager: TaskManagerStartContract;
@ -186,6 +209,63 @@ export interface FindOptions extends IndexType {
filter?: string;
}
export type BulkEditFields = keyof Pick<Rule, 'actions' | 'tags'>;
export type BulkEditOperation =
| {
operation: 'add' | 'delete' | 'set';
field: Extract<BulkEditFields, 'tags'>;
value: string[];
}
| {
operation: 'add' | 'set';
field: Extract<BulkEditFields, 'actions'>;
value: NormalizedAlertAction[];
};
// schedule, throttle, notifyWhen is commented out before https://github.com/elastic/kibana/issues/124850 will be implemented
// | {
// operation: 'set';
// field: Extract<BulkEditFields, 'schedule'>;
// value: Rule['schedule'];
// }
// | {
// operation: 'set';
// field: Extract<BulkEditFields, 'throttle'>;
// value: Rule['throttle'];
// }
// | {
// operation: 'set';
// field: Extract<BulkEditFields, 'notifyWhen'>;
// value: Rule['notifyWhen'];
// };
type RuleParamsModifier<Params extends RuleTypeParams> = (params: Params) => Promise<Params>;
export interface BulkEditOptionsFilter<Params extends RuleTypeParams> {
filter?: string | KueryNode;
operations: BulkEditOperation[];
paramsModifier?: RuleParamsModifier<Params>;
}
export interface BulkEditOptionsIds<Params extends RuleTypeParams> {
ids: string[];
operations: BulkEditOperation[];
paramsModifier?: RuleParamsModifier<Params>;
}
export type BulkEditOptions<Params extends RuleTypeParams> =
| BulkEditOptionsFilter<Params>
| BulkEditOptionsIds<Params>;
export interface BulkEditError {
message: string;
rule: {
id: string;
name: string;
};
}
export interface AggregateOptions extends IndexType {
search?: string;
defaultSearchOperator?: 'AND' | 'OR';
@ -281,6 +361,10 @@ const extractedSavedObjectParamReferenceNamePrefix = 'param:';
// NOTE: Changing this prefix will require a migration to update the prefix in all existing `rule` saved objects
const preconfiguredConnectorActionRefPrefix = 'preconfigured:';
const MAX_RULES_NUMBER_FOR_BULK_EDIT = 10000;
const API_KEY_GENERATE_CONCURRENCY = 50;
const RULE_TYPE_CHECKS_CONCURRENCY = 50;
const alertingAuthorizationFilterOpts: AlertingAuthorizationFilterOpts = {
type: AlertingAuthorizationFilterType.KQL,
fieldNames: { ruleTypeId: 'alert.attributes.alertTypeId', consumer: 'alert.attributes.consumer' },
@ -456,11 +540,12 @@ export class RulesClient {
);
} catch (e) {
// Avoid unused API key
markApiKeyForInvalidation(
{ apiKey: rawRule.apiKey },
await bulkMarkApiKeysForInvalidation(
{ apiKeys: rawRule.apiKey ? [rawRule.apiKey] : [] },
this.logger,
this.unsecuredSavedObjectsClient
);
throw e;
}
if (data.enabled) {
@ -1069,8 +1154,8 @@ export class RulesClient {
await Promise.all([
taskIdToRemove ? this.taskManager.removeIfExists(taskIdToRemove) : null,
apiKeyToInvalidate
? markApiKeyForInvalidation(
{ apiKey: apiKeyToInvalidate },
? bulkMarkApiKeysForInvalidation(
{ apiKeys: [apiKeyToInvalidate] },
this.logger,
this.unsecuredSavedObjectsClient
)
@ -1146,8 +1231,8 @@ export class RulesClient {
await Promise.all([
alertSavedObject.attributes.apiKey
? markApiKeyForInvalidation(
{ apiKey: alertSavedObject.attributes.apiKey },
? bulkMarkApiKeysForInvalidation(
{ apiKeys: [alertSavedObject.attributes.apiKey] },
this.logger,
this.unsecuredSavedObjectsClient
)
@ -1246,11 +1331,12 @@ export class RulesClient {
);
} catch (e) {
// Avoid unused API key
markApiKeyForInvalidation(
{ apiKey: createAttributes.apiKey },
await bulkMarkApiKeysForInvalidation(
{ apiKeys: createAttributes.apiKey ? [createAttributes.apiKey] : [] },
this.logger,
this.unsecuredSavedObjectsClient
);
throw e;
}
@ -1271,6 +1357,349 @@ export class RulesClient {
);
}
public async bulkEdit<Params extends RuleTypeParams>(
options: BulkEditOptions<Params>
): Promise<{
rules: Array<SanitizedRule<Params>>;
errors: BulkEditError[];
total: number;
}> {
const queryFilter = (options as BulkEditOptionsFilter<Params>).filter;
const ids = (options as BulkEditOptionsIds<Params>).ids;
if (ids && queryFilter) {
throw Boom.badRequest(
"Both 'filter' and 'ids' are supplied. Define either 'ids' or 'filter' properties in method arguments"
);
}
let qNodeQueryFilter: null | KueryNode;
if (!queryFilter) {
qNodeQueryFilter = null;
} else if (typeof queryFilter === 'string') {
qNodeQueryFilter = fromKueryExpression(queryFilter);
} else {
qNodeQueryFilter = queryFilter;
}
const qNodeFilter = ids ? convertRuleIdsToKueryNode(ids) : qNodeQueryFilter;
let authorizationTuple;
try {
authorizationTuple = await this.authorization.getFindAuthorizationFilter(
AlertingAuthorizationEntity.Rule,
alertingAuthorizationFilterOpts
);
} catch (error) {
this.auditLogger?.log(
ruleAuditEvent({
action: RuleAuditAction.BULK_EDIT,
error,
})
);
throw error;
}
const { filter: authorizationFilter } = authorizationTuple;
const qNodeFilterWithAuth =
authorizationFilter && qNodeFilter
? nodeBuilder.and([qNodeFilter, authorizationFilter as KueryNode])
: qNodeFilter;
const { aggregations, total } = await this.unsecuredSavedObjectsClient.find<
RawRule,
RuleBulkEditAggregation
>({
filter: qNodeFilterWithAuth,
page: 1,
perPage: 0,
type: 'alert',
aggs: {
alertTypeId: {
multi_terms: {
terms: [
{ field: 'alert.attributes.alertTypeId' },
{ field: 'alert.attributes.consumer' },
],
},
},
},
});
if (total > MAX_RULES_NUMBER_FOR_BULK_EDIT) {
throw Boom.badRequest(
`More than ${MAX_RULES_NUMBER_FOR_BULK_EDIT} rules matched for bulk edit`
);
}
const buckets = aggregations?.alertTypeId.buckets;
if (buckets === undefined) {
throw Error('No rules found for bulk edit');
}
await pMap(
buckets,
async ({ key: [ruleType, consumer] }) => {
this.ruleTypeRegistry.ensureRuleTypeEnabled(ruleType);
try {
await this.authorization.ensureAuthorized({
ruleTypeId: ruleType,
consumer,
operation: WriteOperations.BulkEdit,
entity: AlertingAuthorizationEntity.Rule,
});
} catch (error) {
this.auditLogger?.log(
ruleAuditEvent({
action: RuleAuditAction.BULK_EDIT,
error,
})
);
throw error;
}
},
{ concurrency: RULE_TYPE_CHECKS_CONCURRENCY }
);
const { apiKeysToInvalidate, results, errors } = await retryIfBulkEditConflicts(
this.logger,
`rulesClient.update('operations=${JSON.stringify(options.operations)}, paramsModifier=${
options.paramsModifier ? '[Function]' : undefined
}')`,
(filterKueryNode: KueryNode | null) =>
this.bulkEditOcc({
filter: filterKueryNode,
operations: options.operations,
paramsModifier: options.paramsModifier,
}),
qNodeFilterWithAuth
);
await bulkMarkApiKeysForInvalidation(
{ apiKeys: apiKeysToInvalidate },
this.logger,
this.unsecuredSavedObjectsClient
);
const updatedRules = results.map(({ id, attributes, references }) => {
return this.getAlertFromRaw<Params>(
id,
attributes.alertTypeId as string,
attributes as RawRule,
references,
false
);
});
return { rules: updatedRules, errors, total };
}
private async bulkEditOcc<Params extends RuleTypeParams>({
filter,
operations,
paramsModifier,
}: {
filter: KueryNode | null;
operations: BulkEditOptions<Params>['operations'];
paramsModifier: BulkEditOptions<Params>['paramsModifier'];
}): Promise<{
apiKeysToInvalidate: string[];
rules: Array<SavedObjectsBulkUpdateObject<RawRule>>;
resultSavedObjects: Array<SavedObjectsUpdateResponse<RawRule>>;
errors: BulkEditError[];
}> {
const rulesFinder =
await this.encryptedSavedObjectsClient.createPointInTimeFinderDecryptedAsInternalUser<RawRule>(
{
filter,
type: 'alert',
perPage: 100,
...(this.namespace ? { namespaces: [this.namespace] } : undefined),
}
);
const rules: Array<SavedObjectsBulkUpdateObject<RawRule>> = [];
const errors: BulkEditError[] = [];
const apiKeysToInvalidate: string[] = [];
const apiKeysMap = new Map<string, { oldApiKey?: string; newApiKey?: string }>();
const username = await this.getUserName();
for await (const response of rulesFinder.find()) {
await pMap(
response.saved_objects,
async (rule) => {
try {
if (rule.attributes.apiKey) {
apiKeysMap.set(rule.id, { oldApiKey: rule.attributes.apiKey });
}
const ruleType = this.ruleTypeRegistry.get(rule.attributes.alertTypeId);
let attributes = cloneDeep(rule.attributes);
let ruleActions = {
actions: this.injectReferencesIntoActions(
rule.id,
rule.attributes.actions,
rule.references || []
),
};
for (const operation of operations) {
switch (operation.field) {
case 'actions':
await this.validateActions(ruleType, operation.value);
ruleActions = applyBulkEditOperation(operation, ruleActions);
break;
default:
attributes = applyBulkEditOperation(operation, attributes);
}
}
// validate schedule interval
if (attributes.schedule.interval) {
const isIntervalInvalid =
parseDuration(attributes.schedule.interval as string) <
this.minimumScheduleIntervalInMs;
if (isIntervalInvalid && this.minimumScheduleInterval.enforce) {
throw Error(
`Error updating rule: the interval is less than the allowed minimum interval of ${this.minimumScheduleInterval.value}`
);
} else if (isIntervalInvalid && !this.minimumScheduleInterval.enforce) {
this.logger.warn(
`Rule schedule interval (${attributes.schedule.interval}) for "${ruleType.id}" rule type with ID "${attributes.id}" is less than the minimum value (${this.minimumScheduleInterval.value}). Running rules at this interval may impact alerting performance. Set "xpack.alerting.rules.minimumScheduleInterval.enforce" to true to prevent such changes.`
);
}
}
const ruleParams = paramsModifier
? await paramsModifier(attributes.params as Params)
: attributes.params;
// validate rule params
const validatedAlertTypeParams = validateRuleTypeParams(
ruleParams,
ruleType.validate?.params
);
const validatedMutatedAlertTypeParams = validateMutatedRuleTypeParams(
validatedAlertTypeParams,
rule.attributes.params,
ruleType.validate?.params
);
const {
actions: rawAlertActions,
references,
params: updatedParams,
} = await this.extractReferences(
ruleType,
ruleActions.actions,
validatedMutatedAlertTypeParams
);
// create API key
let createdAPIKey = null;
try {
createdAPIKey = attributes.enabled
? await this.createAPIKey(this.generateAPIKeyName(ruleType.id, attributes.name))
: null;
} catch (error) {
throw Error(`Error updating rule: could not create API key - ${error.message}`);
}
const apiKeyAttributes = this.apiKeyAsAlertAttributes(createdAPIKey, username);
// collect generated API keys
if (apiKeyAttributes.apiKey) {
apiKeysMap.set(rule.id, {
...apiKeysMap.get(rule.id),
newApiKey: apiKeyAttributes.apiKey,
});
}
// get notifyWhen
const notifyWhen = getRuleNotifyWhenType(
attributes.notifyWhen,
attributes.throttle ?? null
);
const updatedAttributes = this.updateMeta({
...attributes,
...apiKeyAttributes,
params: updatedParams as RawRule['params'],
actions: rawAlertActions,
notifyWhen,
updatedBy: username,
updatedAt: new Date().toISOString(),
});
// add mapped_params
const mappedParams = getMappedParams(updatedParams);
if (Object.keys(mappedParams).length) {
updatedAttributes.mapped_params = mappedParams;
}
rules.push({
...rule,
references,
attributes: updatedAttributes,
});
} catch (error) {
errors.push({
message: error.message,
rule: {
id: rule.id,
name: rule.attributes?.name,
},
});
this.auditLogger?.log(
ruleAuditEvent({
action: RuleAuditAction.BULK_EDIT,
error,
})
);
}
},
{ concurrency: API_KEY_GENERATE_CONCURRENCY }
);
}
let result;
try {
result = await this.unsecuredSavedObjectsClient.bulkUpdate(rules);
} catch (e) {
// avoid unused newly generated API keys
if (apiKeysMap.size > 0) {
await bulkMarkApiKeysForInvalidation(
{
apiKeys: Array.from(apiKeysMap.values()).reduce<string[]>((acc, value) => {
if (value.newApiKey) {
acc.push(value.newApiKey);
}
return acc;
}, []),
},
this.logger,
this.unsecuredSavedObjectsClient
);
}
throw e;
}
result.saved_objects.map(({ id, error }) => {
const oldApiKey = apiKeysMap.get(id)?.oldApiKey;
const newApiKey = apiKeysMap.get(id)?.newApiKey;
// if SO wasn't saved and has new API key it will be invalidated
if (error && newApiKey) {
apiKeysToInvalidate.push(newApiKey);
// if SO saved and has old Api Key it will be invalidate
} else if (!error && oldApiKey) {
apiKeysToInvalidate.push(oldApiKey);
}
});
return { apiKeysToInvalidate, resultSavedObjects: result.saved_objects, errors, rules };
}
private apiKeyAsAlertAttributes(
apiKey: CreateAPIKeyResult | null,
username: string | null
@ -1373,8 +1802,8 @@ export class RulesClient {
await this.unsecuredSavedObjectsClient.update('alert', id, updateAttributes, { version });
} catch (e) {
// Avoid unused API key
markApiKeyForInvalidation(
{ apiKey: updateAttributes.apiKey },
await bulkMarkApiKeysForInvalidation(
{ apiKeys: updateAttributes.apiKey ? [updateAttributes.apiKey] : [] },
this.logger,
this.unsecuredSavedObjectsClient
);
@ -1382,8 +1811,8 @@ export class RulesClient {
}
if (apiKeyToInvalidate) {
await markApiKeyForInvalidation(
{ apiKey: apiKeyToInvalidate },
await bulkMarkApiKeysForInvalidation(
{ apiKeys: [apiKeyToInvalidate] },
this.logger,
this.unsecuredSavedObjectsClient
);
@ -1484,8 +1913,8 @@ export class RulesClient {
await this.unsecuredSavedObjectsClient.update('alert', id, updateAttributes, { version });
} catch (e) {
// Avoid unused API key
markApiKeyForInvalidation(
{ apiKey: updateAttributes.apiKey },
await bulkMarkApiKeysForInvalidation(
{ apiKeys: updateAttributes.apiKey ? [updateAttributes.apiKey] : [] },
this.logger,
this.unsecuredSavedObjectsClient
);
@ -1502,8 +1931,8 @@ export class RulesClient {
scheduledTaskId: scheduledTask.id,
});
if (apiKeyToInvalidate) {
await markApiKeyForInvalidation(
{ apiKey: apiKeyToInvalidate },
await bulkMarkApiKeysForInvalidation(
{ apiKeys: [apiKeyToInvalidate] },
this.logger,
this.unsecuredSavedObjectsClient
);
@ -1642,8 +2071,8 @@ export class RulesClient {
? this.taskManager.removeIfExists(attributes.scheduledTaskId)
: null,
apiKeyToInvalidate
? await markApiKeyForInvalidation(
{ apiKey: apiKeyToInvalidate },
? await bulkMarkApiKeysForInvalidation(
{ apiKeys: [apiKeyToInvalidate] },
this.logger,
this.unsecuredSavedObjectsClient
)

View file

@ -0,0 +1,902 @@
/*
* 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 { schema } from '@kbn/config-schema';
import { RulesClient, ConstructorOptions } from '../rules_client';
import { savedObjectsClientMock, loggingSystemMock } from '@kbn/core/server/mocks';
import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks';
import { ruleTypeRegistryMock } from '../../rule_type_registry.mock';
import { alertingAuthorizationMock } from '../../authorization/alerting_authorization.mock';
import { RecoveredActionGroup, RuleTypeParams } from '../../../common';
import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks';
import { actionsAuthorizationMock } from '@kbn/actions-plugin/server/mocks';
import { AlertingAuthorization } from '../../authorization/alerting_authorization';
import { ActionsAuthorization, ActionsClient } from '@kbn/actions-plugin/server';
import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks';
import { getBeforeSetup, setGlobalDate } from './lib';
import { bulkMarkApiKeysForInvalidation } from '../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation';
jest.mock('../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation', () => ({
bulkMarkApiKeysForInvalidation: jest.fn(),
}));
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 = auditLoggerMock.create();
const kibanaVersion = 'v8.2.0';
const createAPIKeyMock = jest.fn();
const rulesClientParams: jest.Mocked<ConstructorOptions> = {
taskManager,
ruleTypeRegistry,
unsecuredSavedObjectsClient,
authorization: authorization as unknown as AlertingAuthorization,
actionsAuthorization: actionsAuthorization as unknown as ActionsAuthorization,
spaceId: 'default',
namespace: 'default',
getUserName: jest.fn(),
createAPIKey: createAPIKeyMock,
logger: loggingSystemMock.create().get(),
encryptedSavedObjectsClient: encryptedSavedObjects,
getActionsClient: jest.fn(),
getEventLogClient: jest.fn(),
kibanaVersion,
auditLogger,
minimumScheduleInterval: { value: '1m', enforce: false },
};
beforeEach(() => {
getBeforeSetup(rulesClientParams, taskManager, ruleTypeRegistry);
(auditLogger.log as jest.Mock).mockClear();
});
setGlobalDate();
describe('bulkEdit()', () => {
let rulesClient: RulesClient;
let actionsClient: jest.Mocked<ActionsClient>;
const existingRule = {
id: '1',
type: 'alert',
attributes: {
enabled: false,
tags: ['foo'],
alertTypeId: 'myType',
schedule: { interval: '1m' },
consumer: 'myApp',
scheduledTaskId: 'task-123',
params: {},
throttle: null,
notifyWhen: null,
actions: [],
name: 'my rule name',
},
references: [],
version: '123',
};
const existingDecryptedRule = {
...existingRule,
attributes: {
...existingRule.attributes,
apiKey: Buffer.from('123:abc').toString('base64'),
},
};
const mockCreatePointInTimeFinderAsInternalUser = (
response = { saved_objects: [existingDecryptedRule] }
) => {
encryptedSavedObjects.createPointInTimeFinderDecryptedAsInternalUser = jest
.fn()
.mockResolvedValue({
close: jest.fn(),
find: function* asyncGenerator() {
yield response;
},
});
};
beforeEach(async () => {
rulesClient = new RulesClient(rulesClientParams);
rulesClientParams.getActionsClient.mockResolvedValue(actionsClient);
authorization.getFindAuthorizationFilter.mockResolvedValue({
ensureRuleTypeIsAuthorized() {},
});
unsecuredSavedObjectsClient.find.mockResolvedValue({
aggregations: {
alertTypeId: {
buckets: [{ key: ['myType', 'myApp'], key_as_string: 'myType|myApp', doc_count: 1 }],
},
},
saved_objects: [],
per_page: 0,
page: 0,
total: 1,
});
mockCreatePointInTimeFinderAsInternalUser();
unsecuredSavedObjectsClient.bulkUpdate.mockResolvedValue({
saved_objects: [existingRule],
});
ruleTypeRegistry.get.mockReturnValue({
id: 'myType',
name: 'Test',
actionGroups: [
{ id: 'default', name: 'Default' },
{ id: 'custom', name: 'Not the Default' },
],
defaultActionGroupId: 'default',
minimumLicenseRequired: 'basic',
isExportable: true,
recoveryActionGroup: RecoveredActionGroup,
async executor() {},
producer: 'alerts',
});
});
describe('tags operations', () => {
test('should add new tag', async () => {
unsecuredSavedObjectsClient.bulkUpdate.mockResolvedValue({
saved_objects: [
{
id: '1',
type: 'alert',
attributes: {
enabled: true,
tags: ['foo', 'test-1'],
alertTypeId: 'myType',
schedule: { interval: '1m' },
consumer: 'myApp',
scheduledTaskId: 'task-123',
params: {},
throttle: null,
notifyWhen: null,
actions: [],
},
references: [],
version: '123',
},
],
});
const result = await rulesClient.bulkEdit({
filter: '',
operations: [
{
field: 'tags',
operation: 'add',
value: ['test-1'],
},
],
});
expect(result.total).toBe(1);
expect(result.errors).toHaveLength(0);
expect(result.rules).toHaveLength(1);
expect(result.rules[0]).toHaveProperty('tags', ['foo', 'test-1']);
expect(unsecuredSavedObjectsClient.bulkUpdate).toHaveBeenCalledTimes(1);
expect(unsecuredSavedObjectsClient.bulkUpdate.mock.calls[0]).toHaveLength(1);
expect(unsecuredSavedObjectsClient.bulkUpdate.mock.calls[0][0]).toEqual([
expect.objectContaining({
id: '1',
type: 'alert',
attributes: expect.objectContaining({
tags: ['foo', 'test-1'],
}),
}),
]);
});
test('should delete tag', async () => {
unsecuredSavedObjectsClient.bulkUpdate.mockResolvedValue({
saved_objects: [
{
id: '1',
type: 'alert',
attributes: {
enabled: true,
tags: [],
alertTypeId: 'myType',
schedule: { interval: '1m' },
consumer: 'myApp',
scheduledTaskId: 'task-123',
params: {},
throttle: null,
notifyWhen: null,
actions: [],
},
references: [],
version: '123',
},
],
});
const result = await rulesClient.bulkEdit({
filter: '',
operations: [
{
field: 'tags',
operation: 'delete',
value: ['foo'],
},
],
});
expect(result.rules[0]).toHaveProperty('tags', []);
expect(unsecuredSavedObjectsClient.bulkUpdate).toHaveBeenCalledTimes(1);
expect(unsecuredSavedObjectsClient.bulkUpdate.mock.calls[0]).toHaveLength(1);
expect(unsecuredSavedObjectsClient.bulkUpdate.mock.calls[0][0]).toEqual([
expect.objectContaining({
id: '1',
type: 'alert',
attributes: expect.objectContaining({
tags: [],
}),
}),
]);
});
test('should set tags', async () => {
unsecuredSavedObjectsClient.bulkUpdate.mockResolvedValue({
saved_objects: [
{
id: '1',
type: 'alert',
attributes: {
enabled: true,
tags: ['test-1', 'test-2'],
alertTypeId: 'myType',
schedule: { interval: '1m' },
consumer: 'myApp',
scheduledTaskId: 'task-123',
params: {},
throttle: null,
notifyWhen: null,
actions: [],
},
references: [],
version: '123',
},
],
});
const result = await rulesClient.bulkEdit({
filter: '',
operations: [
{
field: 'tags',
operation: 'set',
value: ['test-1', 'test-2'],
},
],
});
expect(result.rules[0]).toHaveProperty('tags', ['test-1', 'test-2']);
expect(unsecuredSavedObjectsClient.bulkUpdate).toHaveBeenCalledTimes(1);
expect(unsecuredSavedObjectsClient.bulkUpdate.mock.calls[0]).toHaveLength(1);
expect(unsecuredSavedObjectsClient.bulkUpdate.mock.calls[0][0]).toEqual([
expect.objectContaining({
id: '1',
type: 'alert',
attributes: expect.objectContaining({
tags: ['test-1', 'test-2'],
}),
}),
]);
});
});
describe('ruleTypes aggregation and validation', () => {
test('should call unsecuredSavedObjectsClient.find for aggregations by alertTypeId and consumer', async () => {
await rulesClient.bulkEdit({
filter: 'alert.attributes.tags: "APM"',
operations: [
{
field: 'tags',
operation: 'add',
value: ['test-1'],
},
],
});
expect(unsecuredSavedObjectsClient.find).toHaveBeenCalledWith({
aggs: {
alertTypeId: {
multi_terms: {
terms: [
{
field: 'alert.attributes.alertTypeId',
},
{
field: 'alert.attributes.consumer',
},
],
},
},
},
filter: {
arguments: [
{
type: 'literal',
value: 'alert.attributes.tags',
},
{
type: 'literal',
value: 'APM',
},
{
type: 'literal',
value: true,
},
],
function: 'is',
type: 'function',
},
page: 1,
perPage: 0,
type: 'alert',
});
});
test('should call unsecuredSavedObjectsClient.find for aggregations when called with ids options', async () => {
await rulesClient.bulkEdit({
ids: ['2', '3'],
operations: [
{
field: 'tags',
operation: 'add',
value: ['test-1'],
},
],
});
expect(unsecuredSavedObjectsClient.find).toHaveBeenCalledWith({
aggs: {
alertTypeId: {
multi_terms: {
terms: [
{
field: 'alert.attributes.alertTypeId',
},
{
field: 'alert.attributes.consumer',
},
],
},
},
},
filter: {
arguments: [
{
arguments: [
{
type: 'literal',
value: 'alert.id',
},
{
type: 'literal',
value: 'alert:2',
},
{
type: 'literal',
value: false,
},
],
function: 'is',
type: 'function',
},
{
arguments: [
{
type: 'literal',
value: 'alert.id',
},
{
type: 'literal',
value: 'alert:3',
},
{
type: 'literal',
value: false,
},
],
function: 'is',
type: 'function',
},
],
function: 'or',
type: 'function',
},
page: 1,
perPage: 0,
type: 'alert',
});
});
test('should throw if number of matched rules greater than 10_000', async () => {
unsecuredSavedObjectsClient.find.mockResolvedValueOnce({
aggregations: {
alertTypeId: {
buckets: [{ key: ['myType', 'myApp'], key_as_string: 'myType|myApp', doc_count: 1 }],
},
},
saved_objects: [],
per_page: 0,
page: 0,
total: 10001,
});
await expect(
rulesClient.bulkEdit({
filter: 'alert.attributes.tags: "APM"',
operations: [
{
field: 'tags',
operation: 'add',
value: ['test-1'],
},
],
})
).rejects.toThrow('More than 10000 rules matched for bulk edit');
});
test('should throw if aggregations result is invalid', async () => {
unsecuredSavedObjectsClient.find.mockResolvedValueOnce({
aggregations: {
alertTypeId: {},
},
saved_objects: [],
per_page: 0,
page: 0,
total: 0,
});
await expect(
rulesClient.bulkEdit({
filter: 'alert.attributes.tags: "APM"',
operations: [
{
field: 'tags',
operation: 'add',
value: ['test-1'],
},
],
})
).rejects.toThrow('No rules found for bulk edit');
});
test('should throw if ruleType is not enabled', async () => {
ruleTypeRegistry.ensureRuleTypeEnabled.mockImplementation(() => {
throw new Error('Not enabled');
});
await expect(
rulesClient.bulkEdit({
filter: 'alert.attributes.tags: "APM"',
operations: [
{
field: 'tags',
operation: 'add',
value: ['test-1'],
},
],
})
).rejects.toThrow('Not enabled');
expect(ruleTypeRegistry.ensureRuleTypeEnabled).toHaveBeenLastCalledWith('myType');
});
test('should throw if ruleType is not authorized', async () => {
authorization.ensureAuthorized.mockImplementation(() => {
throw new Error('Unauthorized');
});
await expect(
rulesClient.bulkEdit({
filter: 'alert.attributes.tags: "APM"',
operations: [
{
field: 'tags',
operation: 'add',
value: ['test-1'],
},
],
})
).rejects.toThrow('Unauthorized');
expect(authorization.ensureAuthorized).toHaveBeenLastCalledWith({
consumer: 'myApp',
entity: 'rule',
operation: 'bulkEdit',
ruleTypeId: 'myType',
});
});
});
describe('apiKeys', () => {
test('should call createPointInTimeFinderDecryptedAsInternalUser that returns api Keys', async () => {
await rulesClient.bulkEdit({
filter: 'alert.attributes.tags: "APM"',
operations: [
{
field: 'tags',
operation: 'add',
value: ['test-1'],
},
],
});
expect(
encryptedSavedObjects.createPointInTimeFinderDecryptedAsInternalUser
).toHaveBeenCalledWith({
filter: {
arguments: [
{
type: 'literal',
value: 'alert.attributes.tags',
},
{
type: 'literal',
value: 'APM',
},
{
type: 'literal',
value: true,
},
],
function: 'is',
type: 'function',
},
perPage: 100,
type: 'alert',
namespaces: ['default'],
});
});
test('should call bulkMarkApiKeysForInvalidation with keys apiKeys to invalidate', async () => {
await rulesClient.bulkEdit({
filter: 'alert.attributes.tags: "APM"',
operations: [
{
field: 'tags',
operation: 'add',
value: ['test-1'],
},
],
});
expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith(
{ apiKeys: ['MTIzOmFiYw=='] },
expect.any(Object),
expect.any(Object)
);
});
test('should call bulkMarkApiKeysForInvalidation to invalidate unused keys if bulkUpdate failed', async () => {
createAPIKeyMock.mockReturnValue({ apiKeysEnabled: true, result: { api_key: '111' } });
mockCreatePointInTimeFinderAsInternalUser({
saved_objects: [
{
...existingDecryptedRule,
attributes: { ...existingDecryptedRule.attributes, enabled: true },
},
],
});
unsecuredSavedObjectsClient.bulkUpdate.mockImplementation(() => {
throw new Error('Fail');
});
await expect(
rulesClient.bulkEdit({
filter: 'alert.attributes.tags: "APM"',
operations: [
{
field: 'tags',
operation: 'add',
value: ['test-1'],
},
],
})
).rejects.toThrow('Fail');
expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1);
expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith(
{ apiKeys: ['dW5kZWZpbmVkOjExMQ=='] },
expect.any(Object),
expect.any(Object)
);
});
test('should call bulkMarkApiKeysForInvalidation to invalidate unused keys if SO update failed', async () => {
createAPIKeyMock.mockReturnValue({ apiKeysEnabled: true, result: { api_key: '111' } });
mockCreatePointInTimeFinderAsInternalUser({
saved_objects: [
{
...existingDecryptedRule,
attributes: { ...existingDecryptedRule.attributes, enabled: true },
},
],
});
unsecuredSavedObjectsClient.bulkUpdate.mockResolvedValue({
saved_objects: [
{
id: '1',
type: 'alert',
attributes: {
enabled: true,
tags: ['foo'],
alertTypeId: 'myType',
schedule: { interval: '1m' },
consumer: 'myApp',
scheduledTaskId: 'task-123',
params: { index: ['test-index-*'] },
throttle: null,
notifyWhen: null,
actions: [],
},
references: [],
version: '123',
error: {
error: 'test failure',
statusCode: 500,
message: 'test failure',
},
},
],
});
await rulesClient.bulkEdit({
filter: 'alert.attributes.tags: "APM"',
operations: [
{
field: 'tags',
operation: 'add',
value: ['test-1'],
},
],
});
expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith(
{ apiKeys: ['dW5kZWZpbmVkOjExMQ=='] },
expect.any(Object),
expect.any(Object)
);
});
test('should not call create apiKey if rule is disabled', async () => {
await rulesClient.bulkEdit({
filter: 'alert.attributes.tags: "APM"',
operations: [
{
field: 'tags',
operation: 'add',
value: ['test-1'],
},
],
});
expect(rulesClientParams.createAPIKey).not.toHaveBeenCalledWith();
});
test('should return error in rule errors if key is not generated', async () => {
mockCreatePointInTimeFinderAsInternalUser({
saved_objects: [
{
...existingDecryptedRule,
attributes: { ...existingDecryptedRule.attributes, enabled: true },
},
],
});
await rulesClient.bulkEdit({
filter: 'alert.attributes.tags: "APM"',
operations: [
{
field: 'tags',
operation: 'add',
value: ['test-1'],
},
],
});
expect(rulesClientParams.createAPIKey).toHaveBeenCalledWith('Alerting: myType/my rule name');
});
});
describe('params validation', () => {
test('should return error for rule that failed params validation', async () => {
ruleTypeRegistry.get.mockReturnValue({
id: '123',
name: 'Test',
actionGroups: [{ id: 'default', name: 'Default' }],
defaultActionGroupId: 'default',
minimumLicenseRequired: 'basic',
isExportable: true,
recoveryActionGroup: RecoveredActionGroup,
validate: {
params: schema.object({
param1: schema.string(),
}),
},
async executor() {},
producer: 'alerts',
});
const result = await rulesClient.bulkEdit({
filter: 'alert.attributes.tags: "APM"',
operations: [
{
field: 'tags',
operation: 'add',
value: ['test-1'],
},
],
});
expect(result.errors).toHaveLength(1);
expect(result.errors[0]).toHaveProperty(
'message',
'params invalid: [param1]: expected value of type [string] but got [undefined]'
);
expect(result.errors[0]).toHaveProperty('rule.id', '1');
expect(result.errors[0]).toHaveProperty('rule.name', 'my rule name');
});
test('should validate mutatedParams for rules', async () => {
ruleTypeRegistry.get.mockReturnValue({
id: '123',
name: 'Test',
actionGroups: [{ id: 'default', name: 'Default' }],
defaultActionGroupId: 'default',
minimumLicenseRequired: 'basic',
isExportable: true,
recoveryActionGroup: RecoveredActionGroup,
validate: {
params: {
validate: (rule) => rule as RuleTypeParams,
validateMutatedParams: (rule: unknown) => {
throw Error('Mutated error for rule');
},
},
},
async executor() {},
producer: 'alerts',
});
const result = await rulesClient.bulkEdit({
filter: 'alert.attributes.tags: "APM"',
operations: [
{
field: 'tags',
operation: 'add',
value: ['test-1'],
},
],
});
expect(result.errors).toHaveLength(1);
expect(result.errors[0]).toHaveProperty(
'message',
'Mutated params invalid: Mutated error for rule'
);
expect(result.errors[0]).toHaveProperty('rule.id', '1');
expect(result.errors[0]).toHaveProperty('rule.name', 'my rule name');
});
});
describe('attributes validation', () => {
test('should not update saved object and return error if SO has interval less than minimum configured one when enforce = true', async () => {
rulesClient = new RulesClient({
...rulesClientParams,
minimumScheduleInterval: { value: '3m', enforce: true },
});
unsecuredSavedObjectsClient.bulkUpdate.mockResolvedValue({
saved_objects: [],
});
const result = await rulesClient.bulkEdit({
filter: '',
operations: [],
paramsModifier: async (params) => {
params.index = ['test-index-*'];
return params;
},
});
expect(result.errors).toHaveLength(1);
expect(result.rules).toHaveLength(0);
expect(result.errors[0].message).toBe(
'Error updating rule: the interval is less than the allowed minimum interval of 3m'
);
});
});
describe('paramsModifier', () => {
test('should update index pattern params', async () => {
unsecuredSavedObjectsClient.bulkUpdate.mockResolvedValue({
saved_objects: [
{
id: '1',
type: 'alert',
attributes: {
enabled: true,
tags: ['foo'],
alertTypeId: 'myType',
schedule: { interval: '1m' },
consumer: 'myApp',
scheduledTaskId: 'task-123',
params: { index: ['test-index-*'] },
throttle: null,
notifyWhen: null,
actions: [],
},
references: [],
version: '123',
},
],
});
const result = await rulesClient.bulkEdit({
filter: '',
operations: [],
paramsModifier: async (params) => {
params.index = ['test-index-*'];
return params;
},
});
expect(result.errors).toHaveLength(0);
expect(result.rules).toHaveLength(1);
expect(result.rules[0]).toHaveProperty('params.index', ['test-index-*']);
expect(unsecuredSavedObjectsClient.bulkUpdate).toHaveBeenCalledTimes(1);
expect(unsecuredSavedObjectsClient.bulkUpdate.mock.calls[0]).toHaveLength(1);
expect(unsecuredSavedObjectsClient.bulkUpdate.mock.calls[0][0]).toEqual([
expect.objectContaining({
id: '1',
type: 'alert',
attributes: expect.objectContaining({
params: expect.objectContaining({
index: ['test-index-*'],
}),
}),
}),
]);
});
});
describe('method input validation', () => {
test('should throw error when both ids and filter supplied in method call', async () => {
await expect(
rulesClient.bulkEdit({
filter: 'alert.attributes.tags: "APM"',
ids: ['1', '2'],
operations: [
{
field: 'tags',
operation: 'add',
value: ['test-1'],
},
],
})
).rejects.toThrow(
"Both 'filter' and 'ids' are supplied. Define either 'ids' or 'filter' properties in method arguments"
);
});
});
});

View file

@ -20,6 +20,11 @@ import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks';
import { getBeforeSetup, setGlobalDate } from './lib';
import { RecoveredActionGroup } from '../../../common';
import { getDefaultRuleMonitoring } from '../../task_runner/task_runner';
import { bulkMarkApiKeysForInvalidation } from '../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation';
jest.mock('../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation', () => ({
bulkMarkApiKeysForInvalidation: jest.fn(),
}));
jest.mock('@kbn/core/server/saved_objects/service/lib/utils', () => ({
SavedObjectsUtils: {
@ -2119,25 +2124,17 @@ describe('create()', () => {
result: { id: '123', name: '123', api_key: 'abc' },
});
unsecuredSavedObjectsClient.create.mockRejectedValueOnce(new Error('Test failure'));
const createdAt = new Date().toISOString();
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
id: '1',
type: 'api_key_pending_invalidation',
attributes: {
apiKeyId: '123',
createdAt,
},
references: [],
});
await expect(rulesClient.create({ data })).rejects.toThrowErrorMatchingInlineSnapshot(
`"Test failure"`
);
expect(taskManager.schedule).not.toHaveBeenCalled();
expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(2);
expect(unsecuredSavedObjectsClient.create.mock.calls[1][1]).toStrictEqual({
apiKeyId: '123',
createdAt,
});
expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(1);
expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1);
expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith(
{ apiKeys: ['MTIzOmFiYw=='] },
expect.any(Object),
expect.any(Object)
);
});
test('fails if task scheduling fails due to conflict', async () => {

View file

@ -16,6 +16,11 @@ import { AlertingAuthorization } from '../../authorization/alerting_authorizatio
import { ActionsAuthorization } from '@kbn/actions-plugin/server';
import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks';
import { getBeforeSetup } from './lib';
import { bulkMarkApiKeysForInvalidation } from '../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation';
jest.mock('../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation', () => ({
bulkMarkApiKeysForInvalidation: jest.fn(),
}));
const taskManager = taskManagerMock.createStart();
const ruleTypeRegistry = ruleTypeRegistryMock.create();
@ -100,21 +105,15 @@ describe('delete()', () => {
});
test('successfully removes an alert', async () => {
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
id: '1',
type: 'api_key_pending_invalidation',
attributes: {
apiKeyId: '123',
createdAt: '2019-02-12T21:01:22.479Z',
},
references: [],
});
const result = await rulesClient.delete({ id: '1' });
expect(result).toEqual({ success: true });
expect(unsecuredSavedObjectsClient.delete).toHaveBeenCalledWith('alert', '1');
expect(taskManager.removeIfExists).toHaveBeenCalledWith('task-123');
expect(unsecuredSavedObjectsClient.create.mock.calls[0][0]).toBe(
'api_key_pending_invalidation'
expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1);
expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith(
{ apiKeys: ['MTIzOmFiYw=='] },
expect.any(Object),
expect.any(Object)
);
expect(encryptedSavedObjects.getDecryptedAsInternalUser).toHaveBeenCalledWith('alert', '1', {
namespace: 'default',
@ -124,15 +123,6 @@ describe('delete()', () => {
test('falls back to SOC.get when getDecryptedAsInternalUser throws an error', async () => {
encryptedSavedObjects.getDecryptedAsInternalUser.mockRejectedValue(new Error('Fail'));
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
id: '1',
type: 'api_key_pending_invalidation',
attributes: {
apiKeyId: '123',
createdAt: '2019-02-12T21:01:22.479Z',
},
references: [],
});
const result = await rulesClient.delete({ id: '1' });
expect(result).toEqual({ success: true });
@ -159,15 +149,6 @@ describe('delete()', () => {
});
test(`doesn't invalidate API key when apiKey is null`, async () => {
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
id: '1',
type: 'api_key_pending_invalidation',
attributes: {
apiKeyId: '123',
createdAt: '2019-02-12T21:01:22.479Z',
},
references: [],
});
encryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValue({
...existingAlert,
attributes: {
@ -183,24 +164,15 @@ describe('delete()', () => {
test('swallows error when invalidate API key throws', async () => {
unsecuredSavedObjectsClient.create.mockRejectedValueOnce(new Error('Fail'));
await rulesClient.delete({ id: '1' });
expect(unsecuredSavedObjectsClient.create.mock.calls[0][0]).toBe(
'api_key_pending_invalidation'
);
expect(rulesClientParams.logger.error).toHaveBeenCalledWith(
'Failed to mark for API key [id="MTIzOmFiYw=="] for invalidation: Fail'
expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1);
expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith(
{ apiKeys: ['MTIzOmFiYw=='] },
expect.any(Object),
expect.any(Object)
);
});
test('swallows error when getDecryptedAsInternalUser throws an error', async () => {
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
id: '1',
type: 'api_key_pending_invalidation',
attributes: {
apiKeyId: '123',
createdAt: '2019-02-12T21:01:22.479Z',
},
references: [],
});
encryptedSavedObjects.getDecryptedAsInternalUser.mockRejectedValue(new Error('Fail'));
await rulesClient.delete({ id: '1' });

View file

@ -14,11 +14,15 @@ import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/s
import { actionsAuthorizationMock } from '@kbn/actions-plugin/server/mocks';
import { AlertingAuthorization } from '../../authorization/alerting_authorization';
import { ActionsAuthorization } from '@kbn/actions-plugin/server';
import { InvalidatePendingApiKey } from '../../types';
import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks';
import { getBeforeSetup, setGlobalDate } from './lib';
import { eventLoggerMock } from '@kbn/event-log-plugin/server/event_logger.mock';
import { TaskStatus } from '@kbn/task-manager-plugin/server';
import { bulkMarkApiKeysForInvalidation } from '../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation';
jest.mock('../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation', () => ({
bulkMarkApiKeysForInvalidation: jest.fn(),
}));
jest.mock('uuid', () => ({
v4: () => '5f6aa57d-3e22-484e-bae8-cbed868f4d28',
@ -188,15 +192,6 @@ describe('disable()', () => {
});
test('disables an alert', async () => {
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
id: '1',
type: 'api_key_pending_invalidation',
attributes: {
apiKeyId: '123',
createdAt: '2019-02-12T21:01:22.479Z',
},
references: [],
});
await rulesClient.disable({ id: '1' });
expect(unsecuredSavedObjectsClient.get).not.toHaveBeenCalled();
expect(encryptedSavedObjects.getDecryptedAsInternalUser).toHaveBeenCalledWith('alert', '1', {
@ -235,21 +230,15 @@ describe('disable()', () => {
}
);
expect(taskManager.removeIfExists).toHaveBeenCalledWith('task-123');
expect(
(unsecuredSavedObjectsClient.create.mock.calls[0][1] as InvalidatePendingApiKey).apiKeyId
).toBe('123');
expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1);
expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith(
{ apiKeys: ['MTIzOmFiYw=='] },
expect.any(Object),
expect.any(Object)
);
});
test('disables the rule with calling event log to "recover" the alert instances from the task state', async () => {
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
id: '1',
type: 'api_key_pending_invalidation',
attributes: {
apiKeyId: '123',
createdAt: '2019-02-12T21:01:22.479Z',
},
references: [],
});
const scheduledTaskId = 'task-123';
taskManager.get.mockResolvedValue({
id: scheduledTaskId,
@ -317,9 +306,12 @@ describe('disable()', () => {
}
);
expect(taskManager.removeIfExists).toHaveBeenCalledWith('task-123');
expect(
(unsecuredSavedObjectsClient.create.mock.calls[0][1] as InvalidatePendingApiKey).apiKeyId
).toBe('123');
expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1);
expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith(
{ apiKeys: ['MTIzOmFiYw=='] },
expect.any(Object),
expect.any(Object)
);
expect(eventLogger.logEvent).toHaveBeenCalledTimes(1);
expect(eventLogger.logEvent.mock.calls[0][0]).toStrictEqual({
@ -362,15 +354,6 @@ describe('disable()', () => {
});
test('disables the rule even if unable to retrieve task manager doc to generate recovery event log events', async () => {
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
id: '1',
type: 'api_key_pending_invalidation',
attributes: {
apiKeyId: '123',
createdAt: '2019-02-12T21:01:22.479Z',
},
references: [],
});
taskManager.get.mockRejectedValueOnce(new Error('Fail'));
await rulesClient.disable({ id: '1' });
expect(unsecuredSavedObjectsClient.get).not.toHaveBeenCalled();
@ -410,9 +393,12 @@ describe('disable()', () => {
}
);
expect(taskManager.removeIfExists).toHaveBeenCalledWith('task-123');
expect(
(unsecuredSavedObjectsClient.create.mock.calls[0][1] as InvalidatePendingApiKey).apiKeyId
).toBe('123');
expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1);
expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith(
{ apiKeys: ['MTIzOmFiYw=='] },
expect.any(Object),
expect.any(Object)
);
expect(eventLogger.logEvent).toHaveBeenCalledTimes(0);
expect(rulesClientParams.logger.warn).toHaveBeenCalledWith(
@ -422,15 +408,6 @@ describe('disable()', () => {
test('falls back when getDecryptedAsInternalUser throws an error', async () => {
encryptedSavedObjects.getDecryptedAsInternalUser.mockRejectedValueOnce(new Error('Fail'));
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
id: '1',
type: 'api_key_pending_invalidation',
attributes: {
apiKeyId: '123',
createdAt: '2019-02-12T21:01:22.479Z',
},
references: [],
});
await rulesClient.disable({ id: '1' });
expect(unsecuredSavedObjectsClient.get).toHaveBeenCalledWith('alert', '1');
@ -483,16 +460,6 @@ describe('disable()', () => {
},
});
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
id: '1',
type: 'api_key_pending_invalidation',
attributes: {
apiKeyId: '123',
createdAt: '2019-02-12T21:01:22.479Z',
},
references: [],
});
await rulesClient.disable({ id: '1' });
expect(unsecuredSavedObjectsClient.update).not.toHaveBeenCalled();
expect(taskManager.removeIfExists).not.toHaveBeenCalled();
@ -500,15 +467,6 @@ describe('disable()', () => {
});
test(`doesn't invalidate when no API key is used`, async () => {
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
id: '1',
type: 'api_key_pending_invalidation',
attributes: {
apiKeyId: '123',
createdAt: '2019-02-12T21:01:22.479Z',
},
references: [],
});
encryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValueOnce(existingAlert);
await rulesClient.disable({ id: '1' });
@ -516,15 +474,6 @@ describe('disable()', () => {
});
test('swallows error when failing to load decrypted saved object', async () => {
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
id: '1',
type: 'api_key_pending_invalidation',
attributes: {
apiKeyId: '123',
createdAt: '2019-02-12T21:01:22.479Z',
},
references: [],
});
encryptedSavedObjects.getDecryptedAsInternalUser.mockRejectedValueOnce(new Error('Fail'));
await rulesClient.disable({ id: '1' });
@ -547,8 +496,11 @@ describe('disable()', () => {
test('swallows error when invalidate API key throws', async () => {
unsecuredSavedObjectsClient.create.mockRejectedValueOnce(new Error('Fail'));
await rulesClient.disable({ id: '1' });
expect(rulesClientParams.logger.error).toHaveBeenCalledWith(
'Failed to mark for API key [id="MTIzOmFiYw=="] for invalidation: Fail'
expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1);
expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith(
{ apiKeys: ['MTIzOmFiYw=='] },
expect.any(Object),
expect.any(Object)
);
});

View file

@ -16,8 +16,12 @@ import { AlertingAuthorization } from '../../authorization/alerting_authorizatio
import { ActionsAuthorization } from '@kbn/actions-plugin/server';
import { TaskStatus } from '@kbn/task-manager-plugin/server';
import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks';
import { InvalidatePendingApiKey } from '../../types';
import { getBeforeSetup, setGlobalDate } from './lib';
import { bulkMarkApiKeysForInvalidation } from '../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation';
jest.mock('../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation', () => ({
bulkMarkApiKeysForInvalidation: jest.fn(),
}));
const taskManager = taskManagerMock.createStart();
const ruleTypeRegistry = ruleTypeRegistryMock.create();
@ -183,7 +187,6 @@ describe('enable()', () => {
});
test('enables a rule', async () => {
const createdAt = new Date().toISOString();
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
...existingAlert,
attributes: {
@ -194,22 +197,12 @@ describe('enable()', () => {
updatedBy: 'elastic',
},
});
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
id: '1',
type: 'api_key_pending_invalidation',
attributes: {
apiKeyId: '123',
createdAt,
},
references: [],
});
await rulesClient.enable({ id: '1' });
expect(unsecuredSavedObjectsClient.get).not.toHaveBeenCalled();
expect(encryptedSavedObjects.getDecryptedAsInternalUser).toHaveBeenCalledWith('alert', '1', {
namespace: 'default',
});
expect(unsecuredSavedObjectsClient.create).not.toBeCalledWith('api_key_pending_invalidation');
expect(rulesClientParams.createAPIKey).toHaveBeenCalled();
expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledWith(
'alert',
@ -273,7 +266,6 @@ describe('enable()', () => {
});
test('invalidates API key if ever one existed prior to updating', async () => {
const createdAt = new Date().toISOString();
encryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValue({
...existingAlert,
attributes: {
@ -281,24 +273,18 @@ describe('enable()', () => {
apiKey: Buffer.from('123:abc').toString('base64'),
},
});
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
id: '1',
type: 'api_key_pending_invalidation',
attributes: {
apiKeyId: '123',
createdAt,
},
references: [],
});
await rulesClient.enable({ id: '1' });
expect(unsecuredSavedObjectsClient.get).not.toHaveBeenCalled();
expect(encryptedSavedObjects.getDecryptedAsInternalUser).toHaveBeenCalledWith('alert', '1', {
namespace: 'default',
});
expect(
(unsecuredSavedObjectsClient.create.mock.calls[0][1] as InvalidatePendingApiKey).apiKeyId
).toBe('123');
expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1);
expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith(
{ apiKeys: ['MTIzOmFiYw=='] },
expect.any(Object),
expect.any(Object)
);
});
test(`doesn't enable already enabled alerts`, async () => {
@ -399,31 +385,24 @@ describe('enable()', () => {
});
test('throws error when failing to update the first time', async () => {
const createdAt = new Date().toISOString();
rulesClientParams.createAPIKey.mockResolvedValueOnce({
apiKeysEnabled: true,
result: { id: '123', name: '123', api_key: 'abc' },
});
unsecuredSavedObjectsClient.update.mockReset();
unsecuredSavedObjectsClient.update.mockRejectedValueOnce(new Error('Fail to update'));
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
id: '1',
type: 'api_key_pending_invalidation',
attributes: {
apiKeyId: '123',
createdAt,
},
references: [],
});
await expect(rulesClient.enable({ id: '1' })).rejects.toThrowErrorMatchingInlineSnapshot(
`"Fail to update"`
);
expect(rulesClientParams.getUserName).toHaveBeenCalled();
expect(rulesClientParams.createAPIKey).toHaveBeenCalled();
expect(
(unsecuredSavedObjectsClient.create.mock.calls[0][1] as InvalidatePendingApiKey).apiKeyId
).toBe('123');
expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1);
expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith(
{ apiKeys: ['MTIzOmFiYw=='] },
expect.any(Object),
expect.any(Object)
);
expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledTimes(1);
expect(taskManager.schedule).not.toHaveBeenCalled();
});
@ -462,7 +441,6 @@ describe('enable()', () => {
});
test('enables a rule if conflict errors received when scheduling a task', async () => {
const createdAt = new Date().toISOString();
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
...existingAlert,
attributes: {
@ -473,15 +451,6 @@ describe('enable()', () => {
updatedBy: 'elastic',
},
});
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
id: '1',
type: 'api_key_pending_invalidation',
attributes: {
apiKeyId: '123',
createdAt,
},
references: [],
});
taskManager.schedule.mockRejectedValueOnce(
Object.assign(new Error('Conflict!'), { statusCode: 409 })
);
@ -491,7 +460,6 @@ describe('enable()', () => {
expect(encryptedSavedObjects.getDecryptedAsInternalUser).toHaveBeenCalledWith('alert', '1', {
namespace: 'default',
});
expect(unsecuredSavedObjectsClient.create).not.toBeCalledWith('api_key_pending_invalidation');
expect(rulesClientParams.createAPIKey).toHaveBeenCalled();
expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledWith(
'alert',

View file

@ -12,7 +12,7 @@ import { savedObjectsClientMock, loggingSystemMock } from '@kbn/core/server/mock
import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks';
import { ruleTypeRegistryMock } from '../../rule_type_registry.mock';
import { alertingAuthorizationMock } from '../../authorization/alerting_authorization.mock';
import { IntervalSchedule, InvalidatePendingApiKey } from '../../types';
import { IntervalSchedule } from '../../types';
import { RecoveredActionGroup } from '../../../common';
import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks';
import { actionsAuthorizationMock } from '@kbn/actions-plugin/server/mocks';
@ -22,6 +22,7 @@ import { ActionsAuthorization, ActionsClient } from '@kbn/actions-plugin/server'
import { TaskStatus } from '@kbn/task-manager-plugin/server';
import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks';
import { getBeforeSetup, setGlobalDate } from './lib';
import { bulkMarkApiKeysForInvalidation } from '../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation';
jest.mock('@kbn/core/server/saved_objects/service/lib/utils', () => ({
SavedObjectsUtils: {
@ -29,6 +30,11 @@ jest.mock('@kbn/core/server/saved_objects/service/lib/utils', () => ({
},
}));
jest.mock('../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation', () => ({
bulkMarkApiKeysForInvalidation: jest.fn(),
}));
const bulkMarkApiKeysForInvalidationMock = bulkMarkApiKeysForInvalidation as jest.Mock;
const taskManager = taskManagerMock.createStart();
const ruleTypeRegistry = ruleTypeRegistryMock.create();
const unsecuredSavedObjectsClient = savedObjectsClientMock.create();
@ -238,15 +244,6 @@ describe('update()', () => {
},
],
});
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
id: '1',
type: 'api_key_pending_invalidation',
attributes: {
apiKeyId: '234',
createdAt: '2019-02-12T21:01:22.479Z',
},
references: [],
});
const result = await rulesClient.update({
id: '1',
data: {
@ -331,7 +328,8 @@ describe('update()', () => {
namespace: 'default',
});
expect(unsecuredSavedObjectsClient.get).not.toHaveBeenCalled();
expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(2);
expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(1);
expect(bulkMarkApiKeysForInvalidationMock).toHaveBeenCalledTimes(1);
expect(unsecuredSavedObjectsClient.create.mock.calls[0]).toHaveLength(3);
expect(unsecuredSavedObjectsClient.create.mock.calls[0][0]).toEqual('alert');
expect(unsecuredSavedObjectsClient.create.mock.calls[0][1]).toMatchInlineSnapshot(`
@ -875,24 +873,6 @@ describe('update()', () => {
},
],
});
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
id: '1',
type: 'api_key_pending_invalidation',
attributes: {
apiKeyId: '234',
createdAt: '2019-02-12T21:01:22.479Z',
},
references: [],
});
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
id: '1',
type: 'api_key_pending_invalidation',
attributes: {
apiKeyId: '234',
createdAt: '2019-02-12T21:01:22.479Z',
},
references: [],
});
const result = await rulesClient.update({
id: '1',
data: {
@ -942,7 +922,15 @@ describe('update()', () => {
"updatedAt": 2019-02-12T21:01:22.479Z,
}
`);
expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(2);
expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(1);
expect(bulkMarkApiKeysForInvalidationMock).toHaveBeenCalledTimes(1);
expect(bulkMarkApiKeysForInvalidationMock).toHaveBeenCalledWith(
{
apiKeys: ['MTIzOmFiYw=='],
},
expect.any(Object),
expect.any(Object)
);
expect(unsecuredSavedObjectsClient.create.mock.calls[0]).toHaveLength(3);
expect(unsecuredSavedObjectsClient.create.mock.calls[0][0]).toEqual('alert');
expect(unsecuredSavedObjectsClient.create.mock.calls[0][1]).toMatchInlineSnapshot(`
@ -1040,15 +1028,6 @@ describe('update()', () => {
},
],
});
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
id: '1',
type: 'api_key_pending_invalidation',
attributes: {
apiKeyId: '234',
createdAt: '2019-02-12T21:01:22.479Z',
},
references: [],
});
const result = await rulesClient.update({
id: '1',
data: {
@ -1099,7 +1078,7 @@ describe('update()', () => {
"updatedAt": 2019-02-12T21:01:22.479Z,
}
`);
expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(2);
expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(1);
expect(unsecuredSavedObjectsClient.create.mock.calls[0]).toHaveLength(3);
expect(unsecuredSavedObjectsClient.create.mock.calls[0][0]).toEqual('alert');
expect(unsecuredSavedObjectsClient.create.mock.calls[0][1]).toMatchInlineSnapshot(`
@ -1322,7 +1301,7 @@ describe('update()', () => {
},
],
});
unsecuredSavedObjectsClient.create.mockRejectedValueOnce(new Error('Fail')); // add ApiKey to invalidate
bulkMarkApiKeysForInvalidationMock.mockImplementationOnce(() => new Error('Fail'));
await rulesClient.update({
id: '1',
data: {
@ -1345,8 +1324,12 @@ describe('update()', () => {
],
},
});
expect(rulesClientParams.logger.error).toHaveBeenCalledWith(
'Failed to mark for API key [id="MTIzOmFiYw=="] for invalidation: Fail'
expect(bulkMarkApiKeysForInvalidationMock).toHaveBeenCalledWith(
{
apiKeys: ['MTIzOmFiYw=='],
},
expect.any(Object),
expect.any(Object)
);
});
@ -1516,9 +1499,14 @@ describe('update()', () => {
},
})
).rejects.toThrowErrorMatchingInlineSnapshot(`"Fail"`);
expect(
(unsecuredSavedObjectsClient.create.mock.calls[1][1] as InvalidatePendingApiKey).apiKeyId
).toBe('234');
expect(bulkMarkApiKeysForInvalidationMock).toHaveBeenCalledTimes(1);
expect(bulkMarkApiKeysForInvalidationMock).toHaveBeenCalledWith(
{
apiKeys: ['MjM0OmFiYw=='],
},
expect.any(Object),
expect.any(Object)
);
});
describe('updating an alert schedule', () => {
@ -1913,15 +1901,6 @@ describe('update()', () => {
},
],
});
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
id: '1',
type: 'api_key_pending_invalidation',
attributes: {
apiKeyId: '234',
createdAt: '2019-02-12T21:01:22.479Z',
},
references: [],
});
await rulesClient.update({
id: '1',
data: {

View file

@ -15,9 +15,14 @@ import { actionsAuthorizationMock } from '@kbn/actions-plugin/server/mocks';
import { AlertingAuthorization } from '../../authorization/alerting_authorization';
import { ActionsAuthorization } from '@kbn/actions-plugin/server';
import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks';
import { InvalidatePendingApiKey } from '../../types';
import { getBeforeSetup, setGlobalDate } from './lib';
import { bulkMarkApiKeysForInvalidation } from '../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation';
jest.mock('../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation', () => ({
bulkMarkApiKeysForInvalidation: jest.fn(),
}));
const bulkMarkApiKeysForInvalidationMock = bulkMarkApiKeysForInvalidation as jest.Mock;
const taskManager = taskManagerMock.createStart();
const ruleTypeRegistry = ruleTypeRegistryMock.create();
const unsecuredSavedObjectsClient = savedObjectsClientMock.create();
@ -89,15 +94,6 @@ describe('updateApiKey()', () => {
beforeEach(() => {
rulesClient = new RulesClient(rulesClientParams);
unsecuredSavedObjectsClient.get.mockResolvedValue(existingAlert);
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
id: '1',
type: 'api_key_pending_invalidation',
attributes: {
apiKeyId: '234',
createdAt: '2019-02-12T21:01:22.479Z',
},
references: [],
});
encryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValue(existingEncryptedAlert);
});
@ -140,8 +136,11 @@ describe('updateApiKey()', () => {
},
{ version: '123' }
);
expect(unsecuredSavedObjectsClient.create.mock.calls[0][0]).toBe(
'api_key_pending_invalidation'
expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1);
expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith(
{ apiKeys: ['MTIzOmFiYw=='] },
expect.any(Object),
expect.any(Object)
);
});
@ -162,15 +161,6 @@ describe('updateApiKey()', () => {
result: { id: '234', name: '123', api_key: 'abc' },
});
encryptedSavedObjects.getDecryptedAsInternalUser.mockRejectedValueOnce(new Error('Fail'));
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
id: '1',
type: 'api_key_pending_invalidation',
attributes: {
apiKeyId: '123',
createdAt: '2019-02-12T21:01:22.479Z',
},
references: [],
});
await rulesClient.updateApiKey({ id: '1' });
expect(unsecuredSavedObjectsClient.get).toHaveBeenCalledWith('alert', '1');
@ -210,33 +200,26 @@ describe('updateApiKey()', () => {
});
test('swallows error when invalidate API key throws', async () => {
unsecuredSavedObjectsClient.create.mockRejectedValueOnce(new Error('Fail'));
bulkMarkApiKeysForInvalidationMock.mockImplementationOnce(() => new Error('Fail'));
await rulesClient.updateApiKey({ id: '1' });
expect(unsecuredSavedObjectsClient.update).toHaveBeenCalled();
expect(unsecuredSavedObjectsClient.create.mock.calls[0][0]).toBe(
'api_key_pending_invalidation'
expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1);
expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith(
{ apiKeys: ['MTIzOmFiYw=='] },
expect.any(Object),
expect.any(Object)
);
});
test('swallows error when getting decrypted object throws', async () => {
encryptedSavedObjects.getDecryptedAsInternalUser.mockRejectedValueOnce(new Error('Fail'));
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
id: '1',
type: 'api_key_pending_invalidation',
attributes: {
apiKeyId: '234',
createdAt: '2019-02-12T21:01:22.479Z',
},
references: [],
});
await rulesClient.updateApiKey({ id: '1' });
expect(rulesClientParams.logger.error).toHaveBeenCalledWith(
'updateApiKey(): Failed to load API key to invalidate on alert 1: Fail'
);
expect(unsecuredSavedObjectsClient.update).toHaveBeenCalled();
expect(unsecuredSavedObjectsClient.create).not.toHaveBeenCalled();
});
test('throws when unsecuredSavedObjectsClient update fails and invalidates newly created API key', async () => {
@ -245,22 +228,16 @@ describe('updateApiKey()', () => {
result: { id: '234', name: '234', api_key: 'abc' },
});
unsecuredSavedObjectsClient.update.mockRejectedValueOnce(new Error('Fail'));
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
id: '1',
type: 'api_key_pending_invalidation',
attributes: {
apiKeyId: '234',
createdAt: '2019-02-12T21:01:22.479Z',
},
references: [],
});
await expect(rulesClient.updateApiKey({ id: '1' })).rejects.toThrowErrorMatchingInlineSnapshot(
`"Fail"`
);
expect(
(unsecuredSavedObjectsClient.create.mock.calls[0][1] as InvalidatePendingApiKey).apiKeyId
).toBe('234');
expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1);
expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith(
{ apiKeys: ['MjM0OmFiYw=='] },
expect.any(Object),
expect.any(Object)
);
});
describe('authorization', () => {

View file

@ -116,7 +116,7 @@ async function update(success: boolean) {
expect(logger.warn).lastCalledWith(`rulesClient.update('alert-id') conflict, exceeded retries`);
return expectConflict(success, err, 'create');
}
expectSuccess(success, 3, 'create');
expectSuccess(success, 2, 'create');
// only checking the debug messages in this test
expect(logger.debug).nthCalledWith(1, `rulesClient.update('alert-id') conflict, retrying ...`);

View file

@ -43,7 +43,7 @@ import {
} from '../common';
export type WithoutQueryAndParams<T> = Pick<T, Exclude<keyof T, 'query' | 'params'>>;
export type SpaceIdToNamespaceFunction = (spaceId?: string) => string | undefined;
export type { RuleTypeParams };
/**
* @public
*/
@ -123,6 +123,7 @@ export type ExecutorType<
export interface RuleTypeParamsValidator<Params extends RuleTypeParams> {
validate: (object: unknown) => Params;
validateMutatedParams?: (mutatedOject: unknown, origObject?: unknown) => Params;
}
export interface RuleType<

View file

@ -99,6 +99,21 @@ const savedObjectWithDecryptedContent = await esoClient.getDecryptedAsInternalU
one would pass to `SavedObjectsClient.get`. These argument allows to specify `namespace` property that, for example, is
required if Saved Object was created within a non-default space.
Alternative option is using `createPointInTimeFinderDecryptedAsInternalUser` API method, that can be used to help page through large sets of saved objects.
Its interface matches interface of the corresponding Saved Objects API `createPointInTimeFinder` method:
```typescript
const finder = await this.encryptedSavedObjectsClient.createPointInTimeFinderDecryptedAsInternalUser({
filter,
type: 'my-saved-object-type',
perPage: 1000,
});
for await (const response of finder.find()) {
// process response
}
```
### Defining migrations
EncryptedSavedObjects rely on standard SavedObject migrations, but due to the additional complexity introduced by the need to decrypt and reencrypt the migrated document, there are some caveats to how we support this.
The good news is, most of this complexity is abstracted away by the plugin and all you need to do is leverage our api.

View file

@ -25,18 +25,19 @@ function createEncryptedSavedObjectsSetupMock(
function createEncryptedSavedObjectsStartMock() {
return {
isEncryptionError: jest.fn(),
getClient: jest.fn((opts) => createEncryptedSavedObjectsClienttMock(opts)),
getClient: jest.fn((opts) => createEncryptedSavedObjectsClientMock(opts)),
} as jest.Mocked<EncryptedSavedObjectsPluginStart>;
}
function createEncryptedSavedObjectsClienttMock(opts?: EncryptedSavedObjectsClientOptions) {
function createEncryptedSavedObjectsClientMock(opts?: EncryptedSavedObjectsClientOptions) {
return {
getDecryptedAsInternalUser: jest.fn(),
createPointInTimeFinderDecryptedAsInternalUser: jest.fn(),
} as jest.Mocked<EncryptedSavedObjectsClient>;
}
export const encryptedSavedObjectsMock = {
createSetup: createEncryptedSavedObjectsSetupMock,
createStart: createEncryptedSavedObjectsStartMock,
createClient: createEncryptedSavedObjectsClienttMock,
createClient: createEncryptedSavedObjectsClientMock,
};

View file

@ -60,10 +60,11 @@ describe('EncryptedSavedObjects Plugin', () => {
`);
expect(startContract.getClient()).toMatchInlineSnapshot(`
Object {
"getDecryptedAsInternalUser": [Function],
}
`);
Object {
"createPointInTimeFinderDecryptedAsInternalUser": [Function],
"getDecryptedAsInternalUser": [Function],
}
`);
});
});
});

View file

@ -166,5 +166,171 @@ describe('#setupSavedObjects', () => {
{ namespace: 'some-ns' }
);
});
it('does not call decryptAttributes if Saved Object type is not registered', async () => {
const mockSavedObject: SavedObject = {
id: 'some-id',
type: 'not-known-type',
attributes: { attrOne: 'one', attrSecret: '*secret*' },
references: [],
};
mockSavedObjectsRepository.get.mockResolvedValue(mockSavedObject);
await expect(
setupContract().getDecryptedAsInternalUser(mockSavedObject.type, mockSavedObject.id, {
namespace: 'some-ns',
})
).resolves.toEqual(mockSavedObject);
expect(mockEncryptedSavedObjectsService.decryptAttributes).toHaveBeenCalledTimes(0);
});
});
describe('#createPointInTimeFinderDecryptedAsInternalUser', () => {
it('includes `namespace` for single-namespace saved objects', async () => {
const mockSavedObject: SavedObject = {
id: 'some-id',
type: 'known-type',
attributes: { attrOne: 'one', attrSecret: '*secret*' },
references: [],
};
mockSavedObjectsRepository.createPointInTimeFinder = jest.fn().mockReturnValue({
close: jest.fn(),
find: function* asyncGenerator() {
yield { saved_objects: [mockSavedObject] };
},
});
mockSavedObjectTypeRegistry.isSingleNamespace.mockReturnValue(true);
const finder = await setupContract().createPointInTimeFinderDecryptedAsInternalUser({
type: 'known-type',
namespaces: ['some-ns'],
});
for await (const res of finder.find()) {
expect(res).toEqual({
saved_objects: [
{
...mockSavedObject,
attributes: { attrOne: 'one', attrSecret: 'secret' },
},
],
});
}
expect(mockEncryptedSavedObjectsService.decryptAttributes).toHaveBeenCalledTimes(1);
expect(mockEncryptedSavedObjectsService.decryptAttributes).toHaveBeenCalledWith(
{ type: mockSavedObject.type, id: mockSavedObject.id, namespace: 'some-ns' },
mockSavedObject.attributes
);
expect(mockSavedObjectsRepository.createPointInTimeFinder).toHaveBeenCalledTimes(1);
expect(mockSavedObjectsRepository.createPointInTimeFinder).toHaveBeenCalledWith(
{ type: 'known-type', namespaces: ['some-ns'] },
undefined
);
});
it('does not include `namespace` for multiple-namespace saved objects', async () => {
const mockSavedObject: SavedObject = {
id: 'some-id',
type: 'known-type',
attributes: { attrOne: 'one', attrSecret: '*secret*' },
references: [],
};
mockSavedObjectsRepository.createPointInTimeFinder = jest.fn().mockReturnValue({
close: jest.fn(),
find: function* asyncGenerator() {
yield { saved_objects: [mockSavedObject] };
},
});
mockSavedObjectTypeRegistry.isSingleNamespace.mockReturnValue(false);
const finder = await setupContract().createPointInTimeFinderDecryptedAsInternalUser({
type: 'known-type',
namespaces: ['some-ns'],
});
for await (const res of finder.find()) {
expect(res).toEqual({
saved_objects: [
{
...mockSavedObject,
attributes: { attrOne: 'one', attrSecret: 'secret' },
},
],
});
}
expect(mockEncryptedSavedObjectsService.decryptAttributes).toHaveBeenCalledTimes(1);
expect(mockEncryptedSavedObjectsService.decryptAttributes).toHaveBeenCalledWith(
{ type: mockSavedObject.type, id: mockSavedObject.id, namespace: undefined },
mockSavedObject.attributes
);
expect(mockSavedObjectsRepository.createPointInTimeFinder).toHaveBeenCalledTimes(1);
expect(mockSavedObjectsRepository.createPointInTimeFinder).toHaveBeenCalledWith(
{ type: 'known-type', namespaces: ['some-ns'] },
undefined
);
});
it('does not call decryptAttributes if Saved Object type is not registered', async () => {
const mockSavedObject: SavedObject = {
id: 'some-id',
type: 'not-known-type',
attributes: { attrOne: 'one', attrSecret: '*secret*' },
references: [],
};
mockSavedObjectsRepository.createPointInTimeFinder = jest.fn().mockReturnValue({
close: jest.fn(),
find: function* asyncGenerator() {
yield { saved_objects: [mockSavedObject] };
},
});
const finder = await setupContract().createPointInTimeFinderDecryptedAsInternalUser({
type: 'not-known-type',
namespaces: ['some-ns'],
});
for await (const res of finder.find()) {
expect(res).toEqual({
saved_objects: [mockSavedObject],
});
}
expect(mockEncryptedSavedObjectsService.decryptAttributes).toHaveBeenCalledTimes(0);
});
it('returns error within Saved Object if decryption failed', async () => {
const mockSavedObject: SavedObject = {
id: 'some-id',
type: 'known-type',
attributes: { attrOne: 'one', attrSecret: '*secret*' },
references: [],
};
mockSavedObjectsRepository.createPointInTimeFinder = jest.fn().mockReturnValue({
close: jest.fn(),
find: function* asyncGenerator() {
yield { saved_objects: [mockSavedObject] };
},
});
mockEncryptedSavedObjectsService.decryptAttributes.mockImplementation(() => {
throw new Error('Test failure');
});
const finder = await setupContract().createPointInTimeFinderDecryptedAsInternalUser({
type: 'known-type',
namespaces: ['some-ns'],
});
for await (const res of finder.find()) {
expect(res.saved_objects[0].error).toHaveProperty('message', 'Test failure');
}
});
});
});

View file

@ -5,11 +5,16 @@
* 2.0.
*/
import pMap from 'p-map';
import type {
ISavedObjectsPointInTimeFinder,
ISavedObjectsRepository,
ISavedObjectTypeRegistry,
SavedObject,
SavedObjectsBaseOptions,
SavedObjectsCreatePointInTimeFinderDependencies,
SavedObjectsCreatePointInTimeFinderOptions,
SavedObjectsServiceSetup,
StartServicesAccessor,
} from '@kbn/core/server';
@ -43,6 +48,31 @@ export interface EncryptedSavedObjectsClient {
id: string,
options?: SavedObjectsBaseOptions
) => Promise<SavedObject<T>>;
/**
* API method, that can be used to help page through large sets of saved objects and returns decrypted properties in result SO.
* Its interface matches interface of the corresponding Saved Objects API `createPointInTimeFinder` method:
*
* @example
* ```ts
* const finder = await this.encryptedSavedObjectsClient.createPointInTimeFinderDecryptedAsInternalUser({
* filter,
* type: 'my-saved-object-type',
* perPage: 1000,
* });
* for await (const response of finder.find()) {
* // process response
* }
* ```
*
* @param findOptions matches interface of corresponding argument of Saved Objects API `createPointInTimeFinder` {@link SavedObjectsCreatePointInTimeFinderOptions}
* @param dependencies matches interface of corresponding argument of Saved Objects API `createPointInTimeFinder` {@link SavedObjectsCreatePointInTimeFinderDependencies}
*
*/
createPointInTimeFinderDecryptedAsInternalUser<T = unknown, A = unknown>(
findOptions: SavedObjectsCreatePointInTimeFinderOptions,
dependencies?: SavedObjectsCreatePointInTimeFinderDependencies
): Promise<ISavedObjectsPointInTimeFinder<T, A>>;
}
export function setupSavedObjects({
@ -84,6 +114,11 @@ export function setupSavedObjects({
): Promise<SavedObject<T>> => {
const [internalRepository, typeRegistry] = await internalRepositoryAndTypeRegistryPromise;
const savedObject = await internalRepository.get(type, id, options);
if (!service.isRegistered(savedObject.type)) {
return savedObject as SavedObject<T>;
}
return {
...savedObject,
attributes: (await service.decryptAttributes(
@ -96,6 +131,61 @@ export function setupSavedObjects({
)) as T,
};
},
createPointInTimeFinderDecryptedAsInternalUser: async <T = unknown, A = unknown>(
findOptions: SavedObjectsCreatePointInTimeFinderOptions,
dependencies?: SavedObjectsCreatePointInTimeFinderDependencies
): Promise<ISavedObjectsPointInTimeFinder<T, A>> => {
const [internalRepository, typeRegistry] = await internalRepositoryAndTypeRegistryPromise;
const finder = internalRepository.createPointInTimeFinder<T, A>(findOptions, dependencies);
const finderAsyncGenerator = finder.find();
async function* encryptedFinder() {
for await (const res of finderAsyncGenerator) {
const encryptedSavedObjects = await pMap(
res.saved_objects,
async (savedObject) => {
if (!service.isRegistered(savedObject.type)) {
return savedObject;
}
const descriptor = {
type: savedObject.type,
id: savedObject.id,
namespace: getDescriptorNamespace(
typeRegistry,
savedObject.type,
findOptions.namespaces
),
};
try {
return {
...savedObject,
attributes: (await service.decryptAttributes(
descriptor,
savedObject.attributes as Record<string, unknown>
)) as T,
};
} catch (error) {
// catch error and enrich SO with it, return stripped attributes. Then consumer of API can decide either proceed
// with only unsecured properties or stop when error happens
const { attributes: strippedAttrs } = await service.stripOrDecryptAttributes(
descriptor,
savedObject.attributes as Record<string, unknown>
);
return { ...savedObject, attributes: strippedAttrs as T, error };
}
},
{ concurrency: 50 }
);
yield { ...res, saved_objects: encryptedSavedObjects };
}
}
return { ...finder, find: () => encryptedFinder() };
},
};
};
}

View file

@ -229,6 +229,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/bulkEdit",
"alerting:1.0.0-zeta1:alert-type/my-feature/rule/unsnooze",
]
`);
@ -326,6 +327,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/bulkEdit",
"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",
@ -383,6 +385,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/bulkEdit",
"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",
@ -488,6 +491,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/bulkEdit",
"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",

View file

@ -34,6 +34,7 @@ const writeOperations: Record<AlertingEntity, string[]> = {
'muteAlert',
'unmuteAlert',
'snooze',
'bulkEdit',
'unsnooze',
],
alert: ['update'],

View file

@ -0,0 +1,619 @@
/*
* 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 type { SanitizedRule } from '@kbn/alerting-plugin/common';
import { UserAtSpaceScenarios } from '../../../scenarios';
import {
checkAAD,
getUrlPrefix,
getTestRuleData,
ObjectRemover,
getConsumerUnauthorizedErrorMessage,
getProducerUnauthorizedErrorMessage,
} from '../../../../common/lib';
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
// eslint-disable-next-line import/no-default-export
export default function createUpdateTests({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const supertestWithoutAuth = getService('supertestWithoutAuth');
describe('bulkEdit', () => {
const objectRemover = new ObjectRemover(supertest);
after(() => objectRemover.removeAll());
for (const scenario of UserAtSpaceScenarios) {
const { user, space } = scenario;
describe(scenario.id, () => {
it('should handle bulk edit of rules 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: createdRule } = await supertest
.post(`${getUrlPrefix(space.id)}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(getTestRuleData({ tags: ['foo'] }))
.expect(200);
objectRemover.add(space.id, createdRule.id, 'rule', 'alerting');
const payload = {
ids: [createdRule.id],
operations: [
{
operation: 'add',
field: 'actions',
value: [
{
id: createdAction.id,
group: 'default',
params: {},
},
],
},
],
};
const response = await supertestWithoutAuth
.post(`${getUrlPrefix(space.id)}/internal/alerting/rules/_bulk_edit`)
.set('kbn-xsrf', 'foo')
.auth(user.username, user.password)
.send(payload);
switch (scenario.id) {
case 'no_kibana_privileges at space1':
case 'space_1_all at space2':
expect(response.body).to.eql({
error: 'Forbidden',
message: 'Unauthorized to find rules for any rule types',
statusCode: 403,
});
expect(response.statusCode).to.eql(403);
break;
case 'global_read at space1':
expect(response.body).to.eql({
error: 'Forbidden',
message: 'Unauthorized to bulkEdit a "test.noop" rule for "alertsFixture"',
statusCode: 403,
});
expect(response.statusCode).to.eql(403);
break;
case 'space_1_all_alerts_none_actions at space1':
expect(response.body).to.eql({
errors: [
{
message: 'Unauthorized to get actions',
rule: {
id: createdRule.id,
name: 'abc',
},
},
],
rules: [],
total: 1,
});
expect(response.statusCode).to.eql(200);
break;
case 'superuser at space1':
case 'space_1_all at space1':
case 'space_1_all_with_restricted_fixture at space1':
expect(response.body.rules[0].actions).to.eql([
{
id: createdAction.id,
group: 'default',
params: {},
connector_type_id: 'test.noop',
},
]);
expect(response.statusCode).to.eql(200);
// Ensure AAD isn't broken
await checkAAD({
supertest,
spaceId: space.id,
type: 'alert',
id: createdRule.id,
});
break;
default:
throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);
}
});
it('should handle bulk edit of multiple rules appropriately', async () => {
const rules = await Promise.all(
Array.from({ length: 10 }).map(() =>
supertest
.post(`${getUrlPrefix(space.id)}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(getTestRuleData({ tags: [`multiple-rules-edit-${scenario.id}`] }))
.expect(200)
)
);
rules.forEach(({ body: rule }) => {
objectRemover.add(space.id, rule.id, 'rule', 'alerting');
});
const payload = {
filter: `alert.attributes.tags: "multiple-rules-edit-${scenario.id}"`,
operations: [
{
operation: 'add',
field: 'tags',
value: ['tag-A'],
},
],
};
const response = await supertestWithoutAuth
.post(`${getUrlPrefix(space.id)}/internal/alerting/rules/_bulk_edit`)
.set('kbn-xsrf', 'foo')
.auth(user.username, user.password)
.send(payload);
switch (scenario.id) {
case 'no_kibana_privileges at space1':
case 'space_1_all at space2':
expect(response.body).to.eql({
error: 'Forbidden',
message: 'Unauthorized to find rules for any rule types',
statusCode: 403,
});
expect(response.statusCode).to.eql(403);
break;
case 'global_read at space1':
expect(response.body).to.eql({
error: 'Forbidden',
message: 'Unauthorized to bulkEdit a "test.noop" rule for "alertsFixture"',
statusCode: 403,
});
expect(response.statusCode).to.eql(403);
break;
case 'space_1_all_alerts_none_actions at space1':
case 'superuser at space1':
case 'space_1_all at space1':
case 'space_1_all_with_restricted_fixture at space1':
response.body.rules.forEach((rule: SanitizedRule) =>
expect(rule.tags).to.eql([`multiple-rules-edit-${scenario.id}`, 'tag-A'])
);
expect(response.body.rules).to.have.length(10);
expect(response.body.errors).to.have.length(0);
expect(response.body.total).to.be(10);
expect(response.statusCode).to.eql(200);
break;
default:
throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);
}
});
it('should handle bulk edit of rules appropriately when consumer is the same as producer', async () => {
const { body: createdRule } = await supertest
.post(`${getUrlPrefix(space.id)}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(
getTestRuleData({
tags: ['foo'],
rule_type_id: 'test.restricted-noop',
consumer: 'alertsRestrictedFixture',
})
)
.expect(200);
objectRemover.add(space.id, createdRule.id, 'rule', 'alerting');
const payload = {
ids: [createdRule.id],
operations: [
{
operation: 'add',
field: 'tags',
value: ['tag-A', 'tag-B'],
},
],
};
const response = await supertestWithoutAuth
.post(`${getUrlPrefix(space.id)}/internal/alerting/rules/_bulk_edit`)
.set('kbn-xsrf', 'foo')
.auth(user.username, user.password)
.send(payload);
switch (scenario.id) {
case 'no_kibana_privileges at space1':
case 'space_1_all at space2':
expect(response.body).to.eql({
error: 'Forbidden',
message: 'Unauthorized to find rules for any rule types',
statusCode: 403,
});
expect(response.statusCode).to.eql(403);
break;
case 'space_1_all at space1':
case 'space_1_all_alerts_none_actions at space1':
expect(response.body).to.eql({ errors: [], rules: [], total: 0 });
expect(response.statusCode).to.eql(200);
break;
case 'global_read at space1':
expect(response.body).to.eql({
error: 'Forbidden',
message: getConsumerUnauthorizedErrorMessage(
'bulkEdit',
'test.restricted-noop',
'alertsRestrictedFixture'
),
statusCode: 403,
});
expect(response.statusCode).to.eql(403);
break;
case 'superuser at space1':
case 'space_1_all_with_restricted_fixture at space1':
expect(response.body.rules[0].tags).to.eql(['foo', 'tag-A', 'tag-B']);
expect(response.statusCode).to.eql(200);
break;
default:
throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);
}
});
it('should handle bulk edit of rules request appropriately when consumer is not the producer', async () => {
const { body: createdRule } = await supertest
.post(`${getUrlPrefix(space.id)}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(
getTestRuleData({
rule_type_id: 'test.unrestricted-noop',
consumer: 'alertsFixture',
})
)
.expect(200);
objectRemover.add(space.id, createdRule.id, 'rule', 'alerting');
const payload = {
ids: [createdRule.id],
operations: [
{
operation: 'add',
field: 'tags',
value: ['tag-A', 'tag-B'],
},
],
};
const response = await supertestWithoutAuth
.post(`${getUrlPrefix(space.id)}/internal/alerting/rules/_bulk_edit`)
.set('kbn-xsrf', 'foo')
.auth(user.username, user.password)
.send(payload);
switch (scenario.id) {
case 'no_kibana_privileges at space1':
case 'space_1_all at space2':
expect(response.body).to.eql({
error: 'Forbidden',
message: 'Unauthorized to find rules for any rule types',
statusCode: 403,
});
expect(response.statusCode).to.eql(403);
break;
case 'global_read at space1':
expect(response.body).to.eql({
error: 'Forbidden',
message: getConsumerUnauthorizedErrorMessage(
'bulkEdit',
'test.unrestricted-noop',
'alertsFixture'
),
statusCode: 403,
});
expect(response.statusCode).to.eql(403);
break;
case 'space_1_all at space1':
case 'space_1_all_alerts_none_actions at space1':
expect(response.body).to.eql({
error: 'Forbidden',
message: getProducerUnauthorizedErrorMessage(
'bulkEdit',
'test.unrestricted-noop',
'alertsRestrictedFixture'
),
statusCode: 403,
});
expect(response.statusCode).to.eql(403);
break;
case 'superuser at space1':
case 'space_1_all_with_restricted_fixture at space1':
expect(response.body.rules[0].tags).to.eql(['foo', 'tag-A', 'tag-B']);
expect(response.statusCode).to.eql(200);
break;
default:
throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);
}
});
it('should handle bulk edit of rules request appropriately when consumer is "alerts"', async () => {
const { body: createdRule } = await supertest
.post(`${getUrlPrefix(space.id)}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(
getTestRuleData({
rule_type_id: 'test.restricted-noop',
consumer: 'alerts',
})
)
.expect(200);
objectRemover.add(space.id, createdRule.id, 'rule', 'alerting');
const payload = {
ids: [createdRule.id],
operations: [
{
operation: 'add',
field: 'tags',
value: ['tag-A', 'tag-B'],
},
],
};
const response = await supertestWithoutAuth
.post(`${getUrlPrefix(space.id)}/internal/alerting/rules/_bulk_edit`)
.set('kbn-xsrf', 'foo')
.auth(user.username, user.password)
.send(payload);
switch (scenario.id) {
case 'no_kibana_privileges at space1':
case 'space_1_all at space2':
expect(response.body).to.eql({
error: 'Forbidden',
message: 'Unauthorized to find rules for any rule types',
statusCode: 403,
});
expect(response.statusCode).to.eql(403);
break;
case 'space_1_all at space1':
case 'space_1_all_alerts_none_actions at space1':
expect(response.body).to.eql({ errors: [], rules: [], total: 0 });
expect(response.statusCode).to.eql(200);
break;
case 'global_read at space1':
expect(response.body).to.eql({
error: 'Forbidden',
message: getProducerUnauthorizedErrorMessage(
'bulkEdit',
'test.restricted-noop',
'alertsRestrictedFixture'
),
statusCode: 403,
});
expect(response.statusCode).to.eql(403);
break;
case 'superuser at space1':
case 'space_1_all_with_restricted_fixture at space1':
expect(response.body.rules[0].tags).to.eql(['foo', 'tag-A', 'tag-B']);
expect(response.statusCode).to.eql(200);
break;
default:
throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);
}
});
it('should handle bulk edit of rules when operation is invalid', async () => {
const payload = {
filter: '',
operations: [
{
operation: 'invalid',
field: 'tags',
value: ['test'],
},
],
};
const response = await supertestWithoutAuth
.post(`${getUrlPrefix(space.id)}/internal/alerting/rules/_bulk_edit`)
.set('kbn-xsrf', 'foo')
.auth(user.username, user.password)
.send(payload);
switch (scenario.id) {
case 'no_kibana_privileges at space1':
case 'space_1_all at space2':
case 'global_read at space1':
case 'space_1_all_alerts_none_actions at space1':
case 'superuser at space1':
case 'space_1_all at space1':
case 'space_1_all_with_restricted_fixture at space1':
expect(response.body).to.eql({
statusCode: 400,
error: 'Bad Request',
message:
'[request body.operations.0]: types that failed validation:\n- [request body.operations.0.0.operation]: types that failed validation:\n - [request body.operations.0.operation.0]: expected value to equal [add]\n - [request body.operations.0.operation.1]: expected value to equal [delete]\n - [request body.operations.0.operation.2]: expected value to equal [set]\n- [request body.operations.0.1.operation]: types that failed validation:\n - [request body.operations.0.operation.0]: expected value to equal [add]\n - [request body.operations.0.operation.1]: expected value to equal [set]',
});
expect(response.statusCode).to.eql(400);
break;
default:
throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);
}
});
it('should handle bulk edit of rules when operation field is invalid', async () => {
const { body: createdRule } = await supertest
.post(`${getUrlPrefix(space.id)}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(getTestRuleData({ tags: ['foo'] }))
.expect(200);
objectRemover.add(space.id, createdRule.id, 'rule', 'alerting');
const payload = {
ids: [createdRule.id],
operations: [
{
operation: 'add',
field: 'test',
value: ['test'],
},
],
};
const response = await supertestWithoutAuth
.post(`${getUrlPrefix(space.id)}/internal/alerting/rules/_bulk_edit`)
.set('kbn-xsrf', 'foo')
.auth(user.username, user.password)
.send(payload);
switch (scenario.id) {
case 'no_kibana_privileges at space1':
case 'space_1_all at space2':
case 'global_read at space1':
case 'space_1_all_alerts_none_actions at space1':
case 'superuser at space1':
case 'space_1_all at space1':
case 'space_1_all_with_restricted_fixture at space1':
expect(response.body).to.eql({
statusCode: 400,
error: 'Bad Request',
message:
'[request body.operations.0]: types that failed validation:\n- [request body.operations.0.0.field]: expected value to equal [tags]\n- [request body.operations.0.1.field]: expected value to equal [actions]',
});
expect(response.statusCode).to.eql(400);
break;
default:
throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);
}
});
it('should handle bulk edit of rules when operation field is invalid', async () => {
const payload = {
filter: '',
operations: [
{
operation: 'add',
field: 'test',
value: ['test'],
},
],
};
const response = await supertestWithoutAuth
.post(`${getUrlPrefix(space.id)}/internal/alerting/rules/_bulk_edit`)
.set('kbn-xsrf', 'foo')
.auth(user.username, user.password)
.send(payload);
switch (scenario.id) {
case 'no_kibana_privileges at space1':
case 'space_1_all at space2':
case 'global_read at space1':
case 'space_1_all_alerts_none_actions at space1':
case 'superuser at space1':
case 'space_1_all at space1':
case 'space_1_all_with_restricted_fixture at space1':
expect(response.body).to.eql({
statusCode: 400,
error: 'Bad Request',
message:
'[request body.operations.0]: types that failed validation:\n- [request body.operations.0.0.field]: expected value to equal [tags]\n- [request body.operations.0.1.field]: expected value to equal [actions]',
});
expect(response.statusCode).to.eql(400);
break;
default:
throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);
}
});
it('should handle bulk edit of rules when both ids and filter supplied in payload', async () => {
const payload = {
filter: 'test',
ids: ['test-id'],
operations: [
{
operation: 'add',
field: 'tags',
value: ['test'],
},
],
};
const response = await supertestWithoutAuth
.post(`${getUrlPrefix(space.id)}/internal/alerting/rules/_bulk_edit`)
.set('kbn-xsrf', 'foo')
.auth(user.username, user.password)
.send(payload);
switch (scenario.id) {
case 'no_kibana_privileges at space1':
case 'space_1_all at space2':
case 'global_read at space1':
case 'space_1_all_alerts_none_actions at space1':
case 'superuser at space1':
case 'space_1_all at space1':
case 'space_1_all_with_restricted_fixture at space1':
expect(response.body).to.eql({
error: 'Bad Request',
message:
"Both 'filter' and 'ids' are supplied. Define either 'ids' or 'filter' properties in method arguments",
statusCode: 400,
});
expect(response.statusCode).to.eql(400);
break;
default:
throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);
}
});
it(`shouldn't update rule from another space`, async () => {
const { body: createdRule } = await supertest
.post(`${getUrlPrefix(space.id)}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(getTestRuleData())
.expect(200);
objectRemover.add(space.id, createdRule.id, 'rule', 'alerting');
const response = await supertestWithoutAuth
.post(`${getUrlPrefix('other')}/internal/alerting/rules/_bulk_edit`)
.set('kbn-xsrf', 'foo')
.auth(user.username, user.password)
.send({
ids: [createdRule.id],
operations: [
{
operation: 'add',
field: 'tags',
value: ['test'],
},
],
});
switch (scenario.id) {
case 'superuser at space1':
case 'global_read at space1':
expect(response.body).to.eql({ rules: [], errors: [], total: 0 });
expect(response.statusCode).to.eql(200);
break;
case 'no_kibana_privileges at space1':
case 'space_1_all at space2':
case 'space_1_all at space1':
case 'space_1_all_alerts_none_actions at space1':
case 'space_1_all_with_restricted_fixture at space1':
expect(response.body).to.eql({
error: 'Forbidden',
message: 'Unauthorized to find rules for any rule types',
statusCode: 403,
});
expect(response.statusCode).to.eql(403);
break;
default:
throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);
}
});
});
}
});
}

View file

@ -30,6 +30,7 @@ export default function alertingTests({ loadTestFile, getService }: FtrProviderC
loadTestFile(require.resolve('./get_alert_state'));
loadTestFile(require.resolve('./get_alert_summary'));
loadTestFile(require.resolve('./rule_types'));
loadTestFile(require.resolve('./bulk_edit'));
});
});
}

View file

@ -0,0 +1,181 @@
/*
* 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 type { SanitizedRule } from '@kbn/alerting-plugin/common';
import { Spaces } from '../../scenarios';
import { checkAAD, getUrlPrefix, getTestRuleData, ObjectRemover } from '../../../common/lib';
import { FtrProviderContext } from '../../../common/ftr_provider_context';
// eslint-disable-next-line import/no-default-export
export default function createUpdateTests({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
describe('bulkEdit', () => {
const objectRemover = new ObjectRemover(supertest);
after(() => objectRemover.removeAll());
it('should bulk edit rule with tags operation', async () => {
const { body: createdRule } = await supertest
.post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(getTestRuleData({ tags: ['default'] }));
objectRemover.add(Spaces.space1.id, createdRule.id, 'rule', 'alerting');
const payload = {
ids: [createdRule.id],
operations: [
{
operation: 'add',
field: 'tags',
value: ['tag-1'],
},
],
};
const bulkEditResponse = await supertest
.post(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rules/_bulk_edit`)
.set('kbn-xsrf', 'foo')
.send(payload);
expect(bulkEditResponse.body.errors).to.have.length(0);
expect(bulkEditResponse.body.rules).to.have.length(1);
expect(bulkEditResponse.body.rules[0].tags).to.eql(['default', 'tag-1']);
const { body: updatedRule } = await supertest
.get(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${createdRule.id}`)
.set('kbn-xsrf', 'foo');
expect(updatedRule.tags).to.eql(['default', 'tag-1']);
// Ensure AAD isn't broken
await checkAAD({
supertest,
spaceId: Spaces.space1.id,
type: 'alert',
id: createdRule.id,
});
});
it('should bulk edit multiple rules with tags operation', async () => {
const rules: SanitizedRule[] = (
await Promise.all(
Array.from({ length: 10 }).map(() =>
supertest
.post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(getTestRuleData({ tags: [`multiple-rules-edit`] }))
.expect(200)
)
)
).map((res) => res.body);
rules.forEach((rule) => {
objectRemover.add(Spaces.space1.id, rule.id, 'rule', 'alerting');
});
const payload = {
filter: `alert.attributes.tags: "multiple-rules-edit"`,
operations: [
{
operation: 'set',
field: 'tags',
value: ['rewritten'],
},
],
};
const bulkEditResponse = await supertest
.post(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rules/_bulk_edit`)
.set('kbn-xsrf', 'foo')
.send(payload);
expect(bulkEditResponse.body.total).to.be(10);
expect(bulkEditResponse.body.errors).to.have.length(0);
expect(bulkEditResponse.body.rules).to.have.length(10);
bulkEditResponse.body.rules.every((rule: { tags: string[] }) =>
expect(rule.tags).to.eql([`rewritten`])
);
const updatedRules: SanitizedRule[] = (
await Promise.all(
rules.map((rule) =>
supertest
.get(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${rule.id}`)
.set('kbn-xsrf', 'foo')
)
)
).map((res) => res.body);
updatedRules.forEach((rule) => {
expect(rule.tags).to.eql([`rewritten`]);
});
});
it(`shouldn't bulk edit rule from another space`, async () => {
const { body: createdRule } = await supertest
.post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(getTestRuleData({ tags: ['default'] }));
objectRemover.add(Spaces.space1.id, createdRule.id, 'rule', 'alerting');
const payload = {
ids: [createdRule.id],
operations: [
{
operation: 'add',
field: 'tags',
value: ['tag-1'],
},
],
};
await supertest
.post(`${getUrlPrefix(Spaces.other.id)}/internal/alerting/rules/_bulk_edit`)
.set('kbn-xsrf', 'foo')
.send(payload)
.expect(200, { rules: [], errors: [], total: 0 });
});
it('should return mapped params after bulk edit', async () => {
const { body: createdRule } = await supertest
.post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(
getTestRuleData({ tags: ['default'], params: { risk_score: 40, severity: 'medium' } })
);
objectRemover.add(Spaces.space1.id, createdRule.id, 'rule', 'alerting');
const payload = {
ids: [createdRule.id],
operations: [
{
operation: 'add',
field: 'tags',
value: ['tag-1'],
},
],
};
const bulkEditResponse = await supertest
.post(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rules/_bulk_edit`)
.set('kbn-xsrf', 'foo')
.send(payload);
expect(bulkEditResponse.body.errors).to.have.length(0);
expect(bulkEditResponse.body.rules).to.have.length(1);
expect(bulkEditResponse.body.rules[0].mapped_params).to.eql({
risk_score: 40,
severity: '40-medium',
});
});
});
}

View file

@ -45,6 +45,7 @@ export default function alertingTests({ loadTestFile, getService }: FtrProviderC
loadTestFile(require.resolve('./ephemeral'));
loadTestFile(require.resolve('./event_log_alerts'));
loadTestFile(require.resolve('./snooze'));
loadTestFile(require.resolve('./bulk_edit'));
loadTestFile(require.resolve('./capped_action_type'));
loadTestFile(require.resolve('./scheduled_task_id'));
// Do not place test files here, due to https://github.com/elastic/kibana/issues/123059

View file

@ -6,7 +6,7 @@
*/
import { schema } from '@kbn/config-schema';
import { IRouter, CoreSetup } from '@kbn/core/server';
import { IRouter, CoreSetup, SavedObject } from '@kbn/core/server';
import { PluginsSetup, PluginsStart } from '.';
export function registerHiddenSORoutes(
@ -42,6 +42,42 @@ export function registerHiddenSORoutes(
}
);
router.get(
{
path: '/api/hidden_saved_objects/create-point-in-time-finder-decrypted-as-internal-user',
validate: { query: schema.object({ type: schema.string() }) },
},
async (context, request, response) => {
const [, { encryptedSavedObjects }] = await core.getStartServices();
const spaceId = deps.spaces.spacesService.getSpaceId(request);
const namespace = deps.spaces.spacesService.spaceIdToNamespace(spaceId);
const { type } = request.query;
let savedObjects: SavedObject[] = [];
const finder = await encryptedSavedObjects
.getClient({
includedHiddenTypes: [type],
})
.createPointInTimeFinderDecryptedAsInternalUser({
type,
...(namespace ? { namespaces: [namespace] } : undefined),
});
for await (const result of finder.find()) {
savedObjects = [...savedObjects, ...result.saved_objects];
}
try {
return response.ok({
body: { saved_objects: savedObjects },
});
} catch (err) {
return response.customError({ body: err, statusCode: 500 });
}
}
);
router.get(
{
path: '/api/hidden_saved_objects/_find',

View file

@ -11,7 +11,9 @@ import {
PluginInitializer,
SavedObjectsNamespaceType,
SavedObjectUnsanitizedDoc,
SavedObject,
} from '@kbn/core/server';
import { schema } from '@kbn/config-schema';
import {
EncryptedSavedObjectsPluginSetup,
EncryptedSavedObjectsPluginStart,
@ -113,6 +115,40 @@ export const plugin: PluginInitializer<void, void, PluginsSetup, PluginsStart> =
}
);
router.get(
{
path: '/api/saved_objects/create-point-in-time-finder-decrypted-as-internal-user',
validate: { query: schema.object({ type: schema.string() }) },
},
async (context, request, response) => {
const [, { encryptedSavedObjects }] = await core.getStartServices();
const spaceId = deps.spaces.spacesService.getSpaceId(request);
const namespace = deps.spaces.spacesService.spaceIdToNamespace(spaceId);
const { type } = request.query;
let savedObjects: SavedObject[] = [];
const finder = await encryptedSavedObjects
.getClient()
.createPointInTimeFinderDecryptedAsInternalUser({
type,
...(namespace ? { namespaces: [namespace] } : undefined),
});
for await (const result of finder.find()) {
savedObjects = [...savedObjects, ...result.saved_objects];
}
try {
return response.ok({
body: { saved_objects: savedObjects },
});
} catch (err) {
return response.customError({ body: err, statusCode: 500 });
}
}
);
registerHiddenSORoutes(router, core, deps, [HIDDEN_SAVED_OBJECT_WITH_SECRET_TYPE]);
},
start() {},

View file

@ -425,6 +425,67 @@ export default function ({ getService }: FtrProviderContext) {
message: 'Failed to encrypt attributes',
});
});
it('#createPointInTimeFinderDecryptedAsInternalUser decrypts and returns all attributes', async () => {
const { body: decryptedResponse } = await supertest
.get(
`${getURLAPIBaseURL()}create-point-in-time-finder-decrypted-as-internal-user?type=${encryptedSavedObjectType}`
)
.expect(200);
expect(decryptedResponse.saved_objects[0].error).to.be(undefined);
expect(decryptedResponse.saved_objects[0].attributes).to.eql(savedObjectOriginalAttributes);
});
it('#createPointInTimeFinderDecryptedAsInternalUser returns error and stripped attributes if AAD attribute has changed', async () => {
const updatedAttributes = { publicProperty: randomness.string() };
const { body: response } = await supertest
.put(`${getURLAPIBaseURL()}${encryptedSavedObjectType}/${savedObject.id}`)
.set('kbn-xsrf', 'xxx')
.send({ attributes: updatedAttributes })
.expect(200);
expect(response.attributes).to.eql({
publicProperty: updatedAttributes.publicProperty,
});
const { body: decryptedResponse } = await supertest.get(
`${getURLAPIBaseURL()}create-point-in-time-finder-decrypted-as-internal-user?type=${encryptedSavedObjectType}`
);
expect(decryptedResponse.saved_objects[0].error.message).to.be(
'Unable to decrypt attribute "privateProperty"'
);
expect(decryptedResponse.saved_objects[0].attributes).to.eql({
publicProperty: updatedAttributes.publicProperty,
publicPropertyExcludedFromAAD: savedObjectOriginalAttributes.publicPropertyExcludedFromAAD,
});
});
it('#createPointInTimeFinderDecryptedAsInternalUser is able to decrypt if non-AAD attribute has changed', async () => {
const updatedAttributes = { publicPropertyExcludedFromAAD: randomness.string() };
const { body: response } = await supertest
.put(`${getURLAPIBaseURL()}${encryptedSavedObjectType}/${savedObject.id}`)
.set('kbn-xsrf', 'xxx')
.send({ attributes: updatedAttributes })
.expect(200);
expect(response.attributes).to.eql({
publicPropertyExcludedFromAAD: updatedAttributes.publicPropertyExcludedFromAAD,
});
const { body: decryptedResponse } = await supertest.get(
`${getURLAPIBaseURL()}create-point-in-time-finder-decrypted-as-internal-user?type=${encryptedSavedObjectType}`
);
expect(decryptedResponse.saved_objects[0].error).to.be(undefined);
expect(decryptedResponse.saved_objects[0].attributes).to.eql({
...savedObjectOriginalAttributes,
publicPropertyExcludedFromAAD: updatedAttributes.publicPropertyExcludedFromAAD,
});
});
}
describe('encrypted saved objects API', () => {