[RAM] 142183 create bulk delete on rules (#142466)

* create base for bulk delete route

* add bulk delete method

* add bulkDelete method for task manager

* first version of retry if conflicts for bulk delete

* move waitBeforeNextRetry to separate file

* add happy path for unit and integration tests

* rewrite retry_if_bulk_delete and add tests

* fix happy path integration test

* add some tests and fix bugs

* make bulk_delete endpoint external

* give up on types for endpoint arguments

* add unit tests for new bulk delete route

* add unit tests for bulk delete rules clients method

* add integrational tests

* api integration test running

* add integrational tests

* use bulk edit constant in log audit

* unskip skiped test

* fix conditional statement for taskIds

* api integration for bulkDelete is done

* small code style changes

* delete comments and rename types

* get rid of pmap without async

* delete not used part of return

* add audit logs for all deleted rules

* add unit tests for audit logs

* delete extra comments and rename constant

* delete extra tests

* fix audit logs

* restrict amount of passed ids to 1000

* fix audit logs again

* fix alerting security tests

* test case when user pass more that 1000 ids

* fix writing and case when you send no args

* fix line in the text

* delete extra test

* fix type for rules we passing to bulk delete

* add catch RuleTypeDisabledError

* wait before next retry func tests

* fix tests for retry function

* fix bulk delete rule client tests

* add to api return task ids failed to be deleted and wrap task manager call in try catch

* fix type for task manager

Co-authored-by: Xavier Mouligneau <xavier.mouligneau@elastic.co>
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Julia 2022-10-25 17:23:33 +02:00 committed by GitHub
parent b733dd067d
commit 6266f5ab48
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 1915 additions and 36 deletions

View file

@ -51,6 +51,7 @@ export enum WriteOperations {
UnmuteAlert = 'unmuteAlert',
Snooze = 'snooze',
BulkEdit = 'bulkEdit',
BulkDelete = 'bulkDelete',
Unsnooze = 'unsnooze',
}

View file

@ -0,0 +1,126 @@
/*
* 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 { bulkDeleteRulesRoute } from './bulk_delete_rules';
import { licenseStateMock } from '../lib/license_state.mock';
import { mockHandlerArguments } from './_mock_handler_arguments';
import { rulesClientMock } from '../rules_client.mock';
import { RuleTypeDisabledError } from '../lib/errors/rule_type_disabled';
import { verifyApiAccess } from '../lib/license_api_access';
const rulesClient = rulesClientMock.create();
jest.mock('../lib/license_api_access', () => ({
verifyApiAccess: jest.fn(),
}));
beforeEach(() => {
jest.resetAllMocks();
});
describe('bulkDeleteRulesRoute', () => {
const bulkDeleteRequest = { filter: '' };
const bulkDeleteResult = { errors: [], total: 1, taskIdsFailedToBeDeleted: [] };
it('should delete rules with proper parameters', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
bulkDeleteRulesRoute({ router, licenseState });
const [config, handler] = router.patch.mock.calls[0];
expect(config.path).toBe('/internal/alerting/rules/_bulk_delete');
rulesClient.bulkDeleteRules.mockResolvedValueOnce(bulkDeleteResult);
const [context, req, res] = mockHandlerArguments(
{ rulesClient },
{
body: bulkDeleteRequest,
},
['ok']
);
expect(await handler(context, req, res)).toEqual({
body: bulkDeleteResult,
});
expect(rulesClient.bulkDeleteRules).toHaveBeenCalledTimes(1);
expect(rulesClient.bulkDeleteRules.mock.calls[0]).toEqual([bulkDeleteRequest]);
expect(res.ok).toHaveBeenCalled();
});
it('ensures the license allows bulk deleting rules', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
rulesClient.bulkDeleteRules.mockResolvedValueOnce(bulkDeleteResult);
bulkDeleteRulesRoute({ router, licenseState });
const [, handler] = router.patch.mock.calls[0];
const [context, req, res] = mockHandlerArguments(
{ rulesClient },
{
body: bulkDeleteRequest,
}
);
await handler(context, req, res);
expect(verifyApiAccess).toHaveBeenCalledWith(licenseState);
});
it('ensures the license check prevents bulk deleting rules', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
(verifyApiAccess as jest.Mock).mockImplementation(() => {
throw new Error('Failure');
});
bulkDeleteRulesRoute({ router, licenseState });
const [, handler] = router.patch.mock.calls[0];
const [context, req, res] = mockHandlerArguments(
{ rulesClient },
{
body: bulkDeleteRequest,
}
);
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();
bulkDeleteRulesRoute({ router, licenseState });
const [, handler] = router.patch.mock.calls[0];
rulesClient.bulkDeleteRules.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,50 @@
/*
* 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 { verifyAccessAndContext, handleDisabledApiKeysError } from './lib';
import { ILicenseState, RuleTypeDisabledError } from '../lib';
import { AlertingRequestHandlerContext, INTERNAL_BASE_ALERTING_API_PATH } from '../types';
export const bulkDeleteRulesRoute = ({
router,
licenseState,
}: {
router: IRouter<AlertingRequestHandlerContext>;
licenseState: ILicenseState;
}) => {
router.patch(
{
path: `${INTERNAL_BASE_ALERTING_API_PATH}/rules/_bulk_delete`,
validate: {
body: schema.object({
filter: schema.maybe(schema.string()),
ids: schema.maybe(schema.arrayOf(schema.string(), { minSize: 1, maxSize: 1000 })),
}),
},
},
handleDisabledApiKeysError(
router.handleLegacyErrors(
verifyAccessAndContext(licenseState, async (context, req, res) => {
const rulesClient = (await context.alerting).getRulesClient();
const { filter, ids } = req.body;
try {
const result = await rulesClient.bulkDeleteRules({ filter, ids });
return res.ok({ body: result });
} catch (e) {
if (e instanceof RuleTypeDisabledError) {
return e.sendResponse(res);
}
throw e;
}
})
)
)
);
};

View file

@ -38,6 +38,7 @@ import { bulkEditInternalRulesRoute } from './bulk_edit_rules';
import { snoozeRuleRoute } from './snooze_rule';
import { unsnoozeRuleRoute } from './unsnooze_rule';
import { runSoonRoute } from './run_soon';
import { bulkDeleteRulesRoute } from './bulk_delete_rules';
export interface RouteOptions {
router: IRouter<AlertingRequestHandlerContext>;
@ -76,6 +77,7 @@ export function defineRoutes(opts: RouteOptions) {
unmuteAlertRoute(router, licenseState);
updateRuleApiKeyRoute(router, licenseState);
bulkEditInternalRulesRoute(router, licenseState);
bulkDeleteRulesRoute({ router, licenseState });
snoozeRuleRoute(router, licenseState);
unsnoozeRuleRoute(router, licenseState);
runSoonRoute(router, licenseState);

View file

@ -36,6 +36,7 @@ const createRulesClientMock = () => {
getActionErrorLog: jest.fn(),
getSpaceId: jest.fn(),
bulkEdit: jest.fn(),
bulkDeleteRules: jest.fn(),
snooze: jest.fn(),
unsnooze: jest.fn(),
calculateIsSnoozedUntil: jest.fn(),

View file

@ -8,5 +8,6 @@
export { mapSortField } from './map_sort_field';
export { validateOperationOnAttributes } from './validate_attributes';
export { retryIfBulkEditConflicts } from './retry_if_bulk_edit_conflicts';
export { retryIfBulkDeleteConflicts } from './retry_if_bulk_delete_conflicts';
export { applyBulkEditOperation } from './apply_bulk_edit_operation';
export { buildKueryNodeFilter } from './build_kuery_node_filter';

View file

@ -0,0 +1,100 @@
/*
* 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 { loggingSystemMock } from '@kbn/core/server/mocks';
import { retryIfBulkDeleteConflicts } from './retry_if_bulk_delete_conflicts';
import { RETRY_IF_CONFLICTS_ATTEMPTS } from './wait_before_next_retry';
const mockFilter: KueryNode = {
type: 'function',
value: 'mock',
};
const mockLogger = loggingSystemMock.create().get();
const mockSuccessfulResult = {
apiKeysToInvalidate: ['apiKey1'],
errors: [],
taskIdsToDelete: ['taskId1'],
};
const error409 = {
message: 'some fake message',
status: 409,
rule: {
id: 'fake_rule_id',
name: 'fake rule name',
},
};
const getOperationConflictsTimes = (times: number) => {
return async () => {
conflictOperationMock();
times--;
if (times >= 0) {
return {
apiKeysToInvalidate: [],
taskIdsToDelete: [],
errors: [error409],
};
}
return mockSuccessfulResult;
};
};
const OperationSuccessful = async () => mockSuccessfulResult;
const conflictOperationMock = jest.fn();
describe('retryIfBulkDeleteConflicts', () => {
beforeEach(() => {
jest.resetAllMocks();
});
test('should work when operation is successful', async () => {
const result = await retryIfBulkDeleteConflicts(mockLogger, OperationSuccessful, mockFilter);
expect(result).toEqual(mockSuccessfulResult);
});
test('should throw error when operation fails', async () => {
await expect(
retryIfBulkDeleteConflicts(
mockLogger,
async () => {
throw Error('Test failure');
},
mockFilter
)
).rejects.toThrowError('Test failure');
});
test(`should return conflict errors when number of retries exceeds ${RETRY_IF_CONFLICTS_ATTEMPTS}`, async () => {
const result = await retryIfBulkDeleteConflicts(
mockLogger,
getOperationConflictsTimes(RETRY_IF_CONFLICTS_ATTEMPTS + 1),
mockFilter
);
expect(result.errors).toEqual([error409]);
expect(mockLogger.warn).toBeCalledWith('Bulk delete rules conflicts, exceeded retries');
});
for (let i = 1; i <= RETRY_IF_CONFLICTS_ATTEMPTS; i++) {
test(`should work when operation conflicts ${i} times`, async () => {
const result = await retryIfBulkDeleteConflicts(
mockLogger,
getOperationConflictsTimes(i),
mockFilter
);
expect(conflictOperationMock.mock.calls.length).toBe(i + 1);
expect(result).toStrictEqual(mockSuccessfulResult);
});
}
});

View file

@ -0,0 +1,130 @@
/*
* 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 } from '@kbn/core/server';
import { convertRuleIdsToKueryNode } from '../../lib';
import { BulkDeleteError } from '../rules_client';
import { waitBeforeNextRetry, RETRY_IF_CONFLICTS_ATTEMPTS } from './wait_before_next_retry';
const MAX_RULES_IDS_IN_RETRY = 1000;
export type BulkDeleteOperation = (filter: KueryNode | null) => Promise<{
apiKeysToInvalidate: string[];
errors: BulkDeleteError[];
taskIdsToDelete: string[];
}>;
interface ReturnRetry {
apiKeysToInvalidate: string[];
errors: BulkDeleteError[];
taskIdsToDelete: string[];
}
/**
* Retries BulkDelete requests
* If in response are presents conflicted savedObjects(409 statusCode), this util constructs filter with failed SO ids and retries bulkDelete operation until
* all SO updated or number of retries exceeded
* @param logger
* @param bulkEditOperation
* @param filter - KueryNode filter
* @param retries - number of retries left
* @param accApiKeysToInvalidate - accumulated apiKeys that need to be invalidated
* @param accErrors - accumulated conflict errors
* @param accTaskIdsToDelete - accumulated task ids
* @returns Promise<ReturnRetry>
*/
export const retryIfBulkDeleteConflicts = async (
logger: Logger,
bulkDeleteOperation: BulkDeleteOperation,
filter: KueryNode | null,
retries: number = RETRY_IF_CONFLICTS_ATTEMPTS,
accApiKeysToInvalidate: string[] = [],
accErrors: BulkDeleteError[] = [],
accTaskIdsToDelete: string[] = []
): Promise<ReturnRetry> => {
try {
const {
apiKeysToInvalidate: currentApiKeysToInvalidate,
errors: currentErrors,
taskIdsToDelete: currentTaskIdsToDelete,
} = await bulkDeleteOperation(filter);
const apiKeysToInvalidate = [...accApiKeysToInvalidate, ...currentApiKeysToInvalidate];
const taskIdsToDelete = [...accTaskIdsToDelete, ...currentTaskIdsToDelete];
const errors =
retries <= 0
? [...accErrors, ...currentErrors]
: [...accErrors, ...currentErrors.filter((error) => error.status !== 409)];
const ruleIdsWithConflictError = currentErrors.reduce<string[]>((acc, error) => {
if (error.status === 409) {
return [...acc, error.rule.id];
}
return acc;
}, []);
if (ruleIdsWithConflictError.length === 0) {
return {
apiKeysToInvalidate,
errors,
taskIdsToDelete,
};
}
if (retries <= 0) {
logger.warn('Bulk delete rules conflicts, exceeded retries');
return {
apiKeysToInvalidate,
errors,
taskIdsToDelete,
};
}
logger.debug(
`Bulk delete rules conflicts, retrying ..., ${ruleIdsWithConflictError.length} saved objects conflicted`
);
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 MAX_RULES_IDS_IN_RETRY
return (
await pMap(
chunk(ruleIdsWithConflictError, MAX_RULES_IDS_IN_RETRY),
async (queryIds) =>
retryIfBulkDeleteConflicts(
logger,
bulkDeleteOperation,
convertRuleIdsToKueryNode(queryIds),
retries - 1,
apiKeysToInvalidate,
errors,
taskIdsToDelete
),
{
concurrency: 1,
}
)
).reduce<ReturnRetry>(
(acc, item) => {
return {
apiKeysToInvalidate: [...acc.apiKeysToInvalidate, ...item.apiKeysToInvalidate],
errors: [...acc.errors, ...item.errors],
taskIdsToDelete: [...acc.taskIdsToDelete, ...item.taskIdsToDelete],
};
},
{ apiKeysToInvalidate: [], errors: [], taskIdsToDelete: [] }
);
} catch (err) {
throw err;
}
};

View file

@ -6,10 +6,8 @@
*/
import { KueryNode } from '@kbn/es-query';
import {
retryIfBulkEditConflicts,
RetryForConflictsAttempts,
} from './retry_if_bulk_edit_conflicts';
import { retryIfBulkEditConflicts } from './retry_if_bulk_edit_conflicts';
import { RETRY_IF_CONFLICTS_ATTEMPTS } from './wait_before_next_retry';
import { loggingSystemMock } from '@kbn/core/server/mocks';
const mockFilter: KueryNode = {
@ -112,11 +110,11 @@ describe('retryIfBulkEditConflicts', () => {
).rejects.toThrowError('Test failure');
});
test(`should return conflict errors when number of retries exceeds ${RetryForConflictsAttempts}`, async () => {
test(`should return conflict errors when number of retries exceeds ${RETRY_IF_CONFLICTS_ATTEMPTS}`, async () => {
const result = await retryIfBulkEditConflicts(
mockLogger,
mockOperationName,
getOperationConflictsTimes(RetryForConflictsAttempts + 1),
getOperationConflictsTimes(RETRY_IF_CONFLICTS_ATTEMPTS + 1),
mockFilter
);
@ -132,7 +130,7 @@ describe('retryIfBulkEditConflicts', () => {
expect(mockLogger.warn).toBeCalledWith(`${mockOperationName} conflicts, exceeded retries`);
});
for (let i = 1; i <= RetryForConflictsAttempts; i++) {
for (let i = 1; i <= RETRY_IF_CONFLICTS_ATTEMPTS; i++) {
test(`should work when operation conflicts ${i} times`, async () => {
const result = await retryIfBulkEditConflicts(
mockLogger,

View file

@ -12,15 +12,7 @@ import { Logger, SavedObjectsBulkUpdateObject, SavedObjectsUpdateResponse } from
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;
import { waitBeforeNextRetry, RETRY_IF_CONFLICTS_ATTEMPTS } from './wait_before_next_retry';
// max number of failed SO ids in one retry filter
const MaxIdsNumberInRetryFilter = 1000;
@ -57,7 +49,7 @@ export const retryIfBulkEditConflicts = async (
name: string,
bulkEditOperation: BulkEditOperation,
filter: KueryNode | null,
retries: number = RetryForConflictsAttempts,
retries: number = RETRY_IF_CONFLICTS_ATTEMPTS,
accApiKeysToInvalidate: string[] = [],
accResults: Array<SavedObjectsUpdateResponse<RawRule>> = [],
accErrors: BulkEditError[] = []
@ -154,13 +146,3 @@ export const retryIfBulkEditConflicts = async (
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

@ -0,0 +1,37 @@
/*
* 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 {
getExponentialDelayMultiplier,
randomDelayMs,
RETRY_IF_CONFLICTS_DELAY,
RETRY_IF_CONFLICTS_ATTEMPTS,
waitBeforeNextRetry,
} from './wait_before_next_retry';
describe('waitBeforeNextRetry', () => {
const randomDelayPart = 0.1;
beforeEach(() => {
jest.spyOn(global.Math, 'random').mockReturnValue(randomDelayPart);
jest.spyOn(window, 'setTimeout');
});
afterEach(() => {
jest.clearAllMocks();
});
for (let i = 1; i <= RETRY_IF_CONFLICTS_ATTEMPTS; i++) {
it(`should set timout for ${i} tries`, async () => {
await waitBeforeNextRetry(i);
expect(setTimeout).toBeCalledTimes(1);
expect(setTimeout).toHaveBeenCalledWith(
expect.any(Function),
RETRY_IF_CONFLICTS_DELAY * getExponentialDelayMultiplier(i) + randomDelayMs
);
});
}
});

View file

@ -0,0 +1,29 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const RETRY_IF_CONFLICTS_ATTEMPTS = 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
export const RETRY_IF_CONFLICTS_DELAY = 250;
export const randomDelayMs = Math.floor(Math.random() * 100);
export const getExponentialDelayMultiplier = (retries: number) =>
1 + (RETRY_IF_CONFLICTS_ATTEMPTS - retries) ** 2;
/**
* exponential delay before retry with adding random delay
*/
export const waitBeforeNextRetry = async (retries: number): Promise<void> => {
const exponentialDelayMultiplier = getExponentialDelayMultiplier(retries);
await new Promise((resolve) =>
setTimeout(resolve, RETRY_IF_CONFLICTS_DELAY * exponentialDelayMultiplier + randomDelayMs)
);
};

View file

@ -33,6 +33,7 @@ import {
SavedObjectsUtils,
SavedObjectAttributes,
SavedObjectsBulkUpdateObject,
SavedObjectsBulkDeleteObject,
SavedObjectsUpdateResponse,
} from '@kbn/core/server';
import { ActionsClient, ActionsAuthorization } from '@kbn/actions-plugin/server';
@ -104,6 +105,7 @@ import {
mapSortField,
validateOperationOnAttributes,
retryIfBulkEditConflicts,
retryIfBulkDeleteConflicts,
applyBulkEditOperation,
buildKueryNodeFilter,
} from './lib';
@ -178,7 +180,7 @@ export interface RuleAggregation {
};
}
export interface RuleBulkEditAggregation {
export interface RuleBulkOperationAggregation {
alertTypeId: {
buckets: Array<{
key: string[];
@ -297,6 +299,16 @@ export type BulkEditOptions<Params extends RuleTypeParams> =
| BulkEditOptionsFilter<Params>
| BulkEditOptionsIds<Params>;
export interface BulkDeleteOptionsFilter {
filter?: string | KueryNode;
}
export interface BulkDeleteOptionsIds {
ids?: string[];
}
export type BulkDeleteOptions = BulkDeleteOptionsFilter | BulkDeleteOptionsIds;
export interface BulkEditError {
message: string;
rule: {
@ -305,6 +317,15 @@ export interface BulkEditError {
};
}
export interface BulkDeleteError {
message: string;
status: number;
rule: {
id: string;
name: string;
};
}
export interface AggregateOptions extends IndexType {
search?: string;
defaultSearchOperator?: 'AND' | 'OR';
@ -433,7 +454,7 @@ 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 MAX_RULES_NUMBER_FOR_BULK_OPERATION = 10000;
const API_KEY_GENERATE_CONCURRENCY = 50;
const RULE_TYPE_CHECKS_CONCURRENCY = 50;
@ -1695,6 +1716,212 @@ export class RulesClient {
);
}
private getAuthorizationFilter = async () => {
try {
const authorizationTuple = await this.authorization.getFindAuthorizationFilter(
AlertingAuthorizationEntity.Rule,
alertingAuthorizationFilterOpts
);
return authorizationTuple.filter;
} catch (error) {
this.auditLogger?.log(
ruleAuditEvent({
action: RuleAuditAction.DELETE,
error,
})
);
throw error;
}
};
public bulkDeleteRules = async (options: BulkDeleteOptions) => {
const filter = (options as BulkDeleteOptionsFilter).filter;
const ids = (options as BulkDeleteOptionsIds).ids;
if (!ids && !filter) {
throw Boom.badRequest(
"Either 'ids' or 'filter' property in method's arguments should be provided"
);
}
if (ids?.length === 0) {
throw Boom.badRequest("'ids' property should not be an empty array");
}
if (ids && filter) {
throw Boom.badRequest(
"Both 'filter' and 'ids' are supplied. Define either 'ids' or 'filter' properties in method's arguments"
);
}
const kueryNodeFilter = ids ? convertRuleIdsToKueryNode(ids) : buildKueryNodeFilter(filter);
const authorizationFilter = await this.getAuthorizationFilter();
const kueryNodeFilterWithAuth =
authorizationFilter && kueryNodeFilter
? nodeBuilder.and([kueryNodeFilter, authorizationFilter as KueryNode])
: kueryNodeFilter;
const { aggregations, total } = await this.unsecuredSavedObjectsClient.find<
RawRule,
RuleBulkOperationAggregation
>({
filter: kueryNodeFilterWithAuth,
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_OPERATION) {
throw Boom.badRequest(
`More than ${MAX_RULES_NUMBER_FOR_BULK_OPERATION} rules matched for bulk delete`
);
}
const buckets = aggregations?.alertTypeId.buckets;
if (buckets === undefined || buckets?.length === 0) {
throw Boom.badRequest('No rules found for bulk delete');
}
await pMap(
buckets,
async ({ key: [ruleType, consumer] }) => {
this.ruleTypeRegistry.ensureRuleTypeEnabled(ruleType);
try {
await this.authorization.ensureAuthorized({
ruleTypeId: ruleType,
consumer,
operation: WriteOperations.BulkDelete,
entity: AlertingAuthorizationEntity.Rule,
});
} catch (error) {
this.auditLogger?.log(
ruleAuditEvent({
action: RuleAuditAction.DELETE,
error,
})
);
throw error;
}
},
{ concurrency: RULE_TYPE_CHECKS_CONCURRENCY }
);
const { apiKeysToInvalidate, errors, taskIdsToDelete } = await retryIfBulkDeleteConflicts(
this.logger,
(filterKueryNode: KueryNode | null) => this.bulkDeleteWithOCC({ filter: filterKueryNode }),
kueryNodeFilterWithAuth
);
const taskIdsFailedToBeDeleted: string[] = [];
if (taskIdsToDelete.length > 0) {
try {
const resultFromDeletingTasks = await this.taskManager.bulkRemoveIfExist(taskIdsToDelete);
resultFromDeletingTasks?.statuses.forEach((status) => {
if (!status.success) {
taskIdsFailedToBeDeleted.push(status.id);
}
});
this.logger.debug(
`Successfully deleted schedules for underlying tasks: ${taskIdsToDelete
.filter((id) => taskIdsFailedToBeDeleted.includes(id))
.join(', ')}`
);
} catch (error) {
this.logger.error(
`Failure to delete schedules for underlying tasks: ${taskIdsToDelete.join(
', '
)}. TaskManager bulkRemoveIfExist failed with Error: ${error.message}`
);
}
}
await bulkMarkApiKeysForInvalidation(
{ apiKeys: apiKeysToInvalidate },
this.logger,
this.unsecuredSavedObjectsClient
);
return { errors, total, taskIdsFailedToBeDeleted };
};
private bulkDeleteWithOCC = async ({ filter }: { filter: KueryNode | null }) => {
const rulesFinder =
await this.encryptedSavedObjectsClient.createPointInTimeFinderDecryptedAsInternalUser<RawRule>(
{
filter,
type: 'alert',
perPage: 100,
...(this.namespace ? { namespaces: [this.namespace] } : undefined),
}
);
const rules: SavedObjectsBulkDeleteObject[] = [];
const apiKeysToInvalidate: string[] = [];
const taskIdsToDelete: string[] = [];
const errors: BulkDeleteError[] = [];
const apiKeyToRuleIdMapping: Record<string, string> = {};
const taskIdToRuleIdMapping: Record<string, string> = {};
const ruleNameToRuleIdMapping: Record<string, string> = {};
for await (const response of rulesFinder.find()) {
for (const rule of response.saved_objects) {
if (rule.attributes.apiKey) {
apiKeyToRuleIdMapping[rule.id] = rule.attributes.apiKey;
}
if (rule.attributes.name) {
ruleNameToRuleIdMapping[rule.id] = rule.attributes.name;
}
if (rule.attributes.scheduledTaskId) {
taskIdToRuleIdMapping[rule.id] = rule.attributes.scheduledTaskId;
}
rules.push(rule);
this.auditLogger?.log(
ruleAuditEvent({
action: RuleAuditAction.DELETE,
outcome: 'unknown',
savedObject: { type: 'alert', id: rule.id },
})
);
}
}
const result = await this.unsecuredSavedObjectsClient.bulkDelete(rules);
result.statuses.forEach((status) => {
if (status.error === undefined) {
if (apiKeyToRuleIdMapping[status.id]) {
apiKeysToInvalidate.push(apiKeyToRuleIdMapping[status.id]);
}
if (taskIdToRuleIdMapping[status.id]) {
taskIdsToDelete.push(taskIdToRuleIdMapping[status.id]);
}
} else {
errors.push({
message: status.error.message ?? 'n/a',
status: status.error.statusCode,
rule: {
id: status.id,
name: ruleNameToRuleIdMapping[status.id] ?? 'n/a',
},
});
}
});
return { apiKeysToInvalidate, errors, taskIdsToDelete };
};
public async bulkEdit<Params extends RuleTypeParams>(
options: BulkEditOptions<Params>
): Promise<{
@ -1737,7 +1964,7 @@ export class RulesClient {
const { aggregations, total } = await this.unsecuredSavedObjectsClient.find<
RawRule,
RuleBulkEditAggregation
RuleBulkOperationAggregation
>({
filter: qNodeFilterWithAuth,
page: 1,
@ -1755,9 +1982,9 @@ export class RulesClient {
},
});
if (total > MAX_RULES_NUMBER_FOR_BULK_EDIT) {
if (total > MAX_RULES_NUMBER_FOR_BULK_OPERATION) {
throw Boom.badRequest(
`More than ${MAX_RULES_NUMBER_FOR_BULK_EDIT} rules matched for bulk edit`
`More than ${MAX_RULES_NUMBER_FOR_BULK_OPERATION} rules matched for bulk edit`
);
}
const buckets = aggregations?.alertTypeId.buckets;

View file

@ -0,0 +1,492 @@
/*
* 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 { RulesClient, ConstructorOptions } from '../rules_client';
import { savedObjectsClientMock } 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 } 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 } 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';
import { loggerMock } from '@kbn/logging-mocks';
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 logger = loggerMock.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,
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('bulkDelete', () => {
let rulesClient: RulesClient;
const existingRule = {
id: 'id1',
type: 'alert',
attributes: {},
references: [],
version: '123',
};
const existingDecryptedRule1 = {
...existingRule,
attributes: {
...existingRule.attributes,
scheduledTaskId: 'taskId1',
apiKey: Buffer.from('123:abc').toString('base64'),
},
};
const existingDecryptedRule2 = {
...existingRule,
id: 'id2',
attributes: {
...existingRule.attributes,
scheduledTaskId: 'taskId2',
apiKey: Buffer.from('321:abc').toString('base64'),
},
};
const mockCreatePointInTimeFinderAsInternalUser = (
response = { saved_objects: [existingDecryptedRule1, existingDecryptedRule2] }
) => {
encryptedSavedObjects.createPointInTimeFinderDecryptedAsInternalUser = jest
.fn()
.mockResolvedValue({
close: jest.fn(),
find: function* asyncGenerator() {
yield response;
},
});
};
beforeEach(async () => {
rulesClient = new RulesClient(rulesClientParams);
authorization.getFindAuthorizationFilter.mockResolvedValue({
ensureRuleTypeIsAuthorized() {},
});
unsecuredSavedObjectsClient.find.mockResolvedValue({
aggregations: {
alertTypeId: {
buckets: [{ key: ['myType', 'myApp'], key_as_string: 'myType|myApp', doc_count: 2 }],
},
},
saved_objects: [],
per_page: 0,
page: 0,
total: 2,
});
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',
});
});
test('should try to delete rules, one successful and one with 500 error', async () => {
mockCreatePointInTimeFinderAsInternalUser();
unsecuredSavedObjectsClient.bulkDelete.mockResolvedValue({
statuses: [
{ id: 'id1', type: 'alert', success: true },
{
id: 'id2',
type: 'alert',
success: false,
error: {
error: '',
message: 'UPS',
statusCode: 500,
},
},
],
});
const result = await rulesClient.bulkDeleteRules({ filter: 'fake_filter' });
expect(unsecuredSavedObjectsClient.bulkDelete).toHaveBeenCalledTimes(1);
expect(unsecuredSavedObjectsClient.bulkDelete).toHaveBeenCalledWith([
existingDecryptedRule1,
existingDecryptedRule2,
]);
expect(taskManager.bulkRemoveIfExist).toHaveBeenCalledTimes(1);
expect(taskManager.bulkRemoveIfExist).toHaveBeenCalledWith(['taskId1']);
expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1);
expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith(
{ apiKeys: ['MTIzOmFiYw=='] },
expect.anything(),
expect.anything()
);
expect(result).toStrictEqual({
errors: [{ message: 'UPS', rule: { id: 'id2', name: 'n/a' }, status: 500 }],
total: 2,
taskIdsFailedToBeDeleted: [],
});
});
test('should try to delete rules, one successful and one with 409 error, which will not be deleted with retry', async () => {
unsecuredSavedObjectsClient.bulkDelete
.mockResolvedValueOnce({
statuses: [
{ id: 'id1', type: 'alert', success: true },
{
id: 'id2',
type: 'alert',
success: false,
error: {
error: '',
message: 'UPS',
statusCode: 409,
},
},
],
})
.mockResolvedValueOnce({
statuses: [
{
id: 'id2',
type: 'alert',
success: false,
error: {
error: '',
message: 'UPS',
statusCode: 409,
},
},
],
})
.mockResolvedValueOnce({
statuses: [
{
id: 'id2',
type: 'alert',
success: false,
error: {
error: '',
message: 'UPS',
statusCode: 409,
},
},
],
});
encryptedSavedObjects.createPointInTimeFinderDecryptedAsInternalUser = jest
.fn()
.mockResolvedValueOnce({
close: jest.fn(),
find: function* asyncGenerator() {
yield { saved_objects: [existingDecryptedRule1, existingDecryptedRule2] };
},
})
.mockResolvedValueOnce({
close: jest.fn(),
find: function* asyncGenerator() {
yield { saved_objects: [existingDecryptedRule2] };
},
})
.mockResolvedValueOnce({
close: jest.fn(),
find: function* asyncGenerator() {
yield { saved_objects: [existingDecryptedRule2] };
},
});
const result = await rulesClient.bulkDeleteRules({ ids: ['id1', 'id2'] });
expect(unsecuredSavedObjectsClient.bulkDelete).toHaveBeenCalledTimes(3);
expect(taskManager.bulkRemoveIfExist).toHaveBeenCalledTimes(1);
expect(taskManager.bulkRemoveIfExist).toHaveBeenCalledWith(['taskId1']);
expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1);
expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith(
{ apiKeys: ['MTIzOmFiYw=='] },
expect.anything(),
expect.anything()
);
expect(result).toStrictEqual({
errors: [{ message: 'UPS', rule: { id: 'id2', name: 'n/a' }, status: 409 }],
total: 2,
taskIdsFailedToBeDeleted: [],
});
});
test('should try to delete rules, one successful and one with 409 error, which successfully will be deleted with retry', async () => {
unsecuredSavedObjectsClient.bulkDelete
.mockResolvedValueOnce({
statuses: [
{ id: 'id1', type: 'alert', success: true },
{
id: 'id2',
type: 'alert',
success: false,
error: {
error: '',
message: 'UPS',
statusCode: 409,
},
},
],
})
.mockResolvedValueOnce({
statuses: [
{
id: 'id2',
type: 'alert',
success: true,
},
],
});
encryptedSavedObjects.createPointInTimeFinderDecryptedAsInternalUser = jest
.fn()
.mockResolvedValueOnce({
close: jest.fn(),
find: function* asyncGenerator() {
yield { saved_objects: [existingDecryptedRule1, existingDecryptedRule2] };
},
})
.mockResolvedValueOnce({
close: jest.fn(),
find: function* asyncGenerator() {
yield { saved_objects: [existingDecryptedRule2] };
},
})
.mockResolvedValueOnce({
close: jest.fn(),
find: function* asyncGenerator() {
yield { saved_objects: [existingDecryptedRule2] };
},
});
const result = await rulesClient.bulkDeleteRules({ ids: ['id1', 'id2'] });
expect(unsecuredSavedObjectsClient.bulkDelete).toHaveBeenCalledTimes(2);
expect(taskManager.bulkRemoveIfExist).toHaveBeenCalledTimes(1);
expect(taskManager.bulkRemoveIfExist).toHaveBeenCalledWith(['taskId1', 'taskId2']);
expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1);
expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith(
{ apiKeys: ['MTIzOmFiYw==', 'MzIxOmFiYw=='] },
expect.anything(),
expect.anything()
);
expect(result).toStrictEqual({
errors: [],
total: 2,
taskIdsFailedToBeDeleted: [],
});
});
test('should thow an error if number of matched rules greater than 10,000', async () => {
unsecuredSavedObjectsClient.find.mockResolvedValue({
aggregations: {
alertTypeId: {
buckets: [{ key: ['myType', 'myApp'], key_as_string: 'myType|myApp', doc_count: 2 }],
},
},
saved_objects: [],
per_page: 0,
page: 0,
total: 10001,
});
await expect(rulesClient.bulkDeleteRules({ filter: 'fake_filter' })).rejects.toThrow(
'More than 10000 rules matched for bulk delete'
);
});
test('should throw an error if we do not get buckets', async () => {
mockCreatePointInTimeFinderAsInternalUser();
unsecuredSavedObjectsClient.find.mockResolvedValue({
aggregations: {
alertTypeId: {},
},
saved_objects: [],
per_page: 0,
page: 0,
total: 2,
});
await expect(rulesClient.bulkDeleteRules({ filter: 'fake_filter' })).rejects.toThrow(
'No rules found for bulk delete'
);
});
describe('taskManager', () => {
test('should return task id if deleting task failed', async () => {
mockCreatePointInTimeFinderAsInternalUser();
unsecuredSavedObjectsClient.bulkDelete.mockResolvedValue({
statuses: [
{ id: 'id1', type: 'alert', success: true },
{ id: 'id2', type: 'alert', success: true },
],
});
taskManager.bulkRemoveIfExist.mockImplementation(async () => ({
statuses: [
{
id: 'taskId1',
type: 'alert',
success: true,
},
{
id: 'taskId2',
type: 'alert',
success: false,
error: {
error: '',
message: 'UPS',
statusCode: 500,
},
},
],
}));
const result = await rulesClient.bulkDeleteRules({ filter: 'fake_filter' });
expect(logger.debug).toBeCalledTimes(1);
expect(logger.debug).toBeCalledWith(
'Successfully deleted schedules for underlying tasks: taskId2'
);
expect(result).toStrictEqual({
errors: [],
total: 2,
taskIdsFailedToBeDeleted: ['taskId2'],
});
});
test('should not throw an error if taskManager throw an error', async () => {
mockCreatePointInTimeFinderAsInternalUser();
unsecuredSavedObjectsClient.bulkDelete.mockResolvedValue({
statuses: [
{ id: 'id1', type: 'alert', success: true },
{ id: 'id2', type: 'alert', success: true },
],
});
taskManager.bulkRemoveIfExist.mockImplementation(() => {
throw new Error('UPS');
});
const result = await rulesClient.bulkDeleteRules({ filter: 'fake_filter' });
expect(logger.error).toBeCalledTimes(1);
expect(logger.error).toBeCalledWith(
'Failure to delete schedules for underlying tasks: taskId1, taskId2. TaskManager bulkRemoveIfExist failed with Error: UPS'
);
expect(result).toStrictEqual({
errors: [],
taskIdsFailedToBeDeleted: [],
total: 2,
});
});
});
describe('auditLogger', () => {
jest.spyOn(auditLogger, 'log').mockImplementation();
test('logs audit event when deleting rules', async () => {
mockCreatePointInTimeFinderAsInternalUser();
unsecuredSavedObjectsClient.bulkDelete.mockResolvedValue({
statuses: [
{ id: 'id1', type: 'alert', success: true },
{ id: 'id2', type: 'alert', success: true },
],
});
await rulesClient.bulkDeleteRules({ filter: 'fake_filter' });
expect(auditLogger.log.mock.calls[0][0]?.event?.action).toEqual('rule_delete');
expect(auditLogger.log.mock.calls[0][0]?.event?.outcome).toEqual('unknown');
expect(auditLogger.log.mock.calls[0][0]?.kibana).toEqual({
saved_object: { id: 'id1', type: 'alert' },
});
expect(auditLogger.log.mock.calls[1][0]?.event?.action).toEqual('rule_delete');
expect(auditLogger.log.mock.calls[1][0]?.event?.outcome).toEqual('unknown');
expect(auditLogger.log.mock.calls[1][0]?.kibana).toEqual({
saved_object: { id: 'id2', type: 'alert' },
});
});
test('logs audit event when authentication failed', async () => {
mockCreatePointInTimeFinderAsInternalUser();
authorization.ensureAuthorized.mockImplementation(() => {
throw new Error('Unauthorized');
});
unsecuredSavedObjectsClient.bulkDelete.mockResolvedValue({
statuses: [{ id: 'id1', type: 'alert', success: true }],
});
await expect(rulesClient.bulkDeleteRules({ filter: 'fake_filter' })).rejects.toThrowError(
'Unauthorized'
);
expect(auditLogger.log.mock.calls[0][0]?.event?.action).toEqual('rule_delete');
expect(auditLogger.log.mock.calls[0][0]?.event?.outcome).toEqual('failure');
});
test('logs audit event when getting an authorization filter failed', async () => {
mockCreatePointInTimeFinderAsInternalUser();
authorization.getFindAuthorizationFilter.mockImplementation(() => {
throw new Error('Error');
});
unsecuredSavedObjectsClient.bulkDelete.mockResolvedValue({
statuses: [{ id: 'id1', type: 'alert', success: true }],
});
await expect(rulesClient.bulkDeleteRules({ filter: 'fake_filter' })).rejects.toThrowError(
'Error'
);
expect(auditLogger.log.mock.calls[0][0]?.event?.action).toEqual('rule_delete');
expect(auditLogger.log.mock.calls[0][0]?.event?.outcome).toEqual('failure');
});
});
});

View file

@ -51,6 +51,9 @@ export function getBeforeSetup(
rulesClientParams.createAPIKey.mockResolvedValue({ apiKeysEnabled: false });
rulesClientParams.getUserName.mockResolvedValue('elastic');
taskManager.runSoon.mockResolvedValue({ id: '' });
taskManager.bulkRemoveIfExist.mockResolvedValue({
statuses: [{ id: 'taskId', type: 'alert', success: true }],
});
const actionsClient = actionsClientMock.create();
actionsClient.getBulk.mockResolvedValueOnce([

View file

@ -233,6 +233,7 @@ describe(`feature_privilege_builder`, () => {
"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/bulkDelete",
"alerting:1.0.0-zeta1:alert-type/my-feature/rule/unsnooze",
]
`);
@ -332,6 +333,7 @@ describe(`feature_privilege_builder`, () => {
"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/bulkDelete",
"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",
@ -391,6 +393,7 @@ describe(`feature_privilege_builder`, () => {
"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/bulkDelete",
"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",
@ -499,6 +502,7 @@ describe(`feature_privilege_builder`, () => {
"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/bulkDelete",
"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

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

View file

@ -0,0 +1,43 @@
/*
* 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 uuid from 'uuid';
import { SavedObjectsErrorHelpers } from '@kbn/core/server';
import { bulkRemoveIfExist } from './bulk_remove_if_exist';
import { taskStoreMock } from '../task_store.mock';
describe('removeIfExists', () => {
const ids = [uuid.v4(), uuid.v4()];
test('removes the tasks by its IDs', async () => {
const ts = taskStoreMock.create({});
expect(await bulkRemoveIfExist(ts, ids)).toBe(undefined);
expect(ts.bulkRemove).toHaveBeenCalledWith(ids);
});
test('handles 404 errors caused by the task not existing', async () => {
const ts = taskStoreMock.create({});
ts.bulkRemove.mockRejectedValue(
SavedObjectsErrorHelpers.createGenericNotFoundError('task', ids[0])
);
expect(await bulkRemoveIfExist(ts, ids)).toBe(undefined);
expect(ts.bulkRemove).toHaveBeenCalledWith(ids);
});
test('throws if any other error is caused by task removal', async () => {
const ts = taskStoreMock.create({});
const error = SavedObjectsErrorHelpers.createInvalidVersionError(uuid.v4());
ts.bulkRemove.mockRejectedValue(error);
expect(bulkRemoveIfExist(ts, ids)).rejects.toBe(error);
expect(ts.bulkRemove).toHaveBeenCalledWith(ids);
});
});

View file

@ -0,0 +1,26 @@
/*
* 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 { SavedObjectsErrorHelpers } from '@kbn/core/server';
import { TaskStore } from '../task_store';
/**
* Removes a task from the store, ignoring a not found error
* Other errors are re-thrown
*
* @param taskStore
* @param taskIds
*/
export async function bulkRemoveIfExist(taskStore: TaskStore, taskIds: string[]) {
try {
return await taskStore.bulkRemove(taskIds);
} catch (err) {
if (!SavedObjectsErrorHelpers.isNotFoundError(err)) {
throw err;
}
}
}

View file

@ -27,6 +27,7 @@ const createStartMock = () => {
ephemeralRunNow: jest.fn(),
ensureScheduled: jest.fn(),
removeIfExists: jest.fn(),
bulkRemoveIfExist: jest.fn(),
supportsEphemeralTasks: jest.fn(),
bulkUpdateSchedules: jest.fn(),
bulkSchedule: jest.fn(),

View file

@ -18,10 +18,12 @@ import {
ServiceStatusLevels,
CoreStatus,
} from '@kbn/core/server';
import type { SavedObjectsBulkDeleteResponse } from '@kbn/core/server';
import { TaskPollingLifecycle } from './polling_lifecycle';
import { TaskManagerConfig } from './config';
import { createInitialMiddleware, addMiddlewareToChain, Middleware } from './lib/middleware';
import { removeIfExists } from './lib/remove_if_exists';
import { bulkRemoveIfExist } from './lib/bulk_remove_if_exist';
import { setupSavedObjects } from './saved_objects';
import { TaskDefinitionRegistry, TaskTypeDictionary, REMOVED_TYPES } from './task_type_dictionary';
import { AggregationOpts, FetchResult, SearchOpts, TaskStore } from './task_store';
@ -33,6 +35,7 @@ import { EphemeralTaskLifecycle } from './ephemeral_task_lifecycle';
import { EphemeralTask, ConcreteTaskInstance } from './task';
import { registerTaskManagerUsageCollector } from './usage';
import { TASK_MANAGER_INDEX } from './constants';
export interface TaskManagerSetupContract {
/**
* @deprecated
@ -59,7 +62,11 @@ export type TaskManagerStartContract = Pick<
> &
Pick<TaskStore, 'fetch' | 'aggregate' | 'get' | 'remove'> & {
removeIfExists: TaskStore['remove'];
} & { supportsEphemeralTasks: () => boolean };
} & {
bulkRemoveIfExist: (ids: string[]) => Promise<SavedObjectsBulkDeleteResponse | undefined>;
} & {
supportsEphemeralTasks: () => boolean;
};
export class TaskManagerPlugin
implements Plugin<TaskManagerSetupContract, TaskManagerStartContract>
@ -248,6 +255,7 @@ export class TaskManagerPlugin
taskStore.aggregate(opts),
get: (id: string) => taskStore.get(id),
remove: (id: string) => taskStore.remove(id),
bulkRemoveIfExist: (ids: string[]) => bulkRemoveIfExist(taskStore, ids),
removeIfExists: (id: string) => removeIfExists(taskStore, id),
schedule: (...args) => taskScheduling.schedule(...args),
bulkSchedule: (...args) => taskScheduling.bulkSchedule(...args),

View file

@ -20,6 +20,7 @@ export const taskStoreMock = {
schedule: jest.fn(),
bulkSchedule: jest.fn(),
bulkUpdate: jest.fn(),
bulkRemove: jest.fn(),
get: jest.fn(),
getLifecycle: jest.fn(),
fetch: jest.fn(),

View file

@ -25,6 +25,8 @@ import { mockLogger } from './test_utils';
const savedObjectsClient = savedObjectsRepositoryMock.create();
const serializer = savedObjectsServiceMock.createSerializer();
const randomId = () => `id-${_.random(1, 20)}`;
beforeEach(() => jest.resetAllMocks());
const mockedDate = new Date('2019-02-12T21:01:22.479Z');
@ -529,6 +531,41 @@ describe('TaskStore', () => {
});
});
describe('bulkRemove', () => {
let store: TaskStore;
const tasksIdsToDelete = [randomId(), randomId()];
beforeAll(() => {
store = new TaskStore({
index: 'tasky',
taskManagerId: '',
serializer,
esClient: elasticsearchServiceMock.createClusterClient().asInternalUser,
definitions: taskDefinitions,
savedObjectsRepository: savedObjectsClient,
});
});
test('removes the tasks with the specified ids', async () => {
const result = await store.bulkRemove(tasksIdsToDelete);
expect(result).toBeUndefined();
expect(savedObjectsClient.bulkDelete).toHaveBeenCalledWith([
{ type: 'task', id: tasksIdsToDelete[0] },
{ type: 'task', id: tasksIdsToDelete[1] },
]);
});
test('pushes error from saved objects client to errors$', async () => {
const firstErrorPromise = store.errors$.pipe(first()).toPromise();
savedObjectsClient.bulkDelete.mockRejectedValue(new Error('Failure'));
await expect(store.bulkRemove(tasksIdsToDelete)).rejects.toThrowErrorMatchingInlineSnapshot(
`"Failure"`
);
expect(await firstErrorPromise).toMatchInlineSnapshot(`[Error: Failure]`);
});
});
describe('get', () => {
let store: TaskStore;
@ -802,5 +839,3 @@ describe('TaskStore', () => {
});
});
});
const randomId = () => `id-${_.random(1, 20)}`;

View file

@ -12,6 +12,7 @@ import { Subject } from 'rxjs';
import { omit, defaults } from 'lodash';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import type { SavedObjectsBulkDeleteResponse } from '@kbn/core/server';
import {
SavedObject,
@ -294,6 +295,22 @@ export class TaskStore {
}
}
/**
* Bulk removes the specified tasks from the index.
*
* @param {SavedObjectsBulkDeleteObject[]} savedObjectsToDelete
* @returns {Promise<SavedObjectsBulkDeleteResponse>}
*/
public async bulkRemove(taskIds: string[]): Promise<SavedObjectsBulkDeleteResponse> {
try {
const savedObjectsToDelete = taskIds.map((taskId) => ({ id: taskId, type: 'task' }));
return await this.savedObjectsRepository.bulkDelete(savedObjectsToDelete);
} catch (e) {
this.errors$.next(e);
throw e;
}
}
/**
* Gets a task by id
*

View file

@ -0,0 +1,563 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import expect from '@kbn/expect';
import { UserAtSpaceScenarios, SuperuserAtSpace1 } from '../../../scenarios';
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
import { getUrlPrefix, getTestRuleData, ObjectRemover } from '../../../../common/lib';
const defaultSuccessfulResponse = { errors: [], total: 1, taskIdsFailedToBeDeleted: [] };
// eslint-disable-next-line import/no-default-export
export default ({ getService }: FtrProviderContext) => {
const supertest = getService('supertest');
const es = getService('es');
const supertestWithoutAuth = getService('supertestWithoutAuth');
describe('bulkDelete', () => {
const objectRemover = new ObjectRemover(supertest);
after(() => objectRemover.removeAll());
const getScheduledTask = async (id: string) => {
return await es.get({
id: `task:${id}`,
index: '.kibana_task_manager',
});
};
for (const scenario of UserAtSpaceScenarios) {
const { user, space } = scenario;
describe(scenario.id, () => {
afterEach(() => objectRemover.removeAll());
it('should handle bulk delete of one rule appropriately based on id', async () => {
const { body: createdRule1 } = await supertest
.post(`${getUrlPrefix(space.id)}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(getTestRuleData())
.expect(200);
const response = await supertestWithoutAuth
.patch(`${getUrlPrefix(space.id)}/internal/alerting/rules/_bulk_delete`)
.set('kbn-xsrf', 'foo')
.send({ ids: [createdRule1.id] })
.auth(user.username, user.password);
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);
objectRemover.add(space.id, createdRule1.id, 'rule', 'alerting');
// Ensure task still exists
await getScheduledTask(createdRule1.scheduled_task_id);
break;
case 'global_read at space1':
expect(response.body).to.eql({
error: 'Forbidden',
message: 'Unauthorized to bulkDelete a "test.noop" rule for "alertsFixture"',
statusCode: 403,
});
expect(response.statusCode).to.eql(403);
objectRemover.add(space.id, createdRule1.id, 'rule', 'alerting');
// Ensure task still exists
await getScheduledTask(createdRule1.scheduled_task_id);
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':
expect(response.body).to.eql(defaultSuccessfulResponse);
expect(response.statusCode).to.eql(200);
try {
await getScheduledTask(createdRule1.scheduled_task_id);
throw new Error('Should have removed scheduled task');
} catch (e) {
expect(e.meta.statusCode).to.eql(404);
}
break;
default:
throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);
}
});
it('should handle bulk delete of one rule appropriately based on id when consumer is the same as producer', async () => {
const { body: createdRule1 } = await supertest
.post(`${getUrlPrefix(space.id)}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(
getTestRuleData({
rule_type_id: 'test.restricted-noop',
consumer: 'alertsRestrictedFixture',
})
)
.expect(200);
const response = await supertestWithoutAuth
.patch(`${getUrlPrefix(space.id)}/internal/alerting/rules/_bulk_delete`)
.set('kbn-xsrf', 'foo')
.send({ ids: [createdRule1.id] })
.auth(user.username, user.password);
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);
objectRemover.add(space.id, createdRule1.id, 'rule', 'alerting');
// Ensure task still exists
await getScheduledTask(createdRule1.scheduled_task_id);
break;
case 'global_read at space1':
expect(response.body).to.eql({
error: 'Forbidden',
message:
'Unauthorized to bulkDelete a "test.restricted-noop" rule for "alertsRestrictedFixture"',
statusCode: 403,
});
expect(response.statusCode).to.eql(403);
objectRemover.add(space.id, createdRule1.id, 'rule', 'alerting');
// Ensure task still exists
await getScheduledTask(createdRule1.scheduled_task_id);
break;
case 'space_1_all at space1':
case 'space_1_all_alerts_none_actions at space1':
expect(response.body).to.eql({
statusCode: 400,
error: 'Bad Request',
message: 'No rules found for bulk delete',
});
expect(response.statusCode).to.eql(400);
objectRemover.add(space.id, createdRule1.id, 'rule', 'alerting');
// Ensure task still exists
await getScheduledTask(createdRule1.scheduled_task_id);
break;
case 'superuser at space1':
case 'space_1_all_with_restricted_fixture at space1':
expect(response.body).to.eql(defaultSuccessfulResponse);
expect(response.statusCode).to.eql(200);
try {
await getScheduledTask(createdRule1.scheduled_task_id);
throw new Error('Should have removed scheduled task');
} catch (e) {
expect(e.meta.statusCode).to.eql(404);
}
break;
default:
throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);
}
});
it('should handle delete alert request appropriately when consumer is not the producer', async () => {
const { body: createdRule1 } = await supertest
.post(`${getUrlPrefix(space.id)}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(
getTestRuleData({
rule_type_id: 'test.restricted-noop',
consumer: 'alertsFixture',
})
)
.expect(200);
const response = await supertestWithoutAuth
.patch(`${getUrlPrefix(space.id)}/internal/alerting/rules/_bulk_delete`)
.set('kbn-xsrf', 'foo')
.send({ ids: [createdRule1.id] })
.auth(user.username, user.password);
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);
objectRemover.add(space.id, createdRule1.id, 'rule', 'alerting');
// Ensure task still exists
await getScheduledTask(createdRule1.scheduled_task_id);
break;
case 'space_1_all at space1':
case 'space_1_all_alerts_none_actions at space1':
case 'space_1_all_with_restricted_fixture at space1':
case 'global_read at space1':
expect(response.body).to.eql({
statusCode: 400,
error: 'Bad Request',
message: 'No rules found for bulk delete',
});
expect(response.statusCode).to.eql(400);
objectRemover.add(space.id, createdRule1.id, 'rule', 'alerting');
// Ensure task still exists
await getScheduledTask(createdRule1.scheduled_task_id);
break;
case 'superuser at space1':
expect(response.body).to.eql(defaultSuccessfulResponse);
expect(response.statusCode).to.eql(200);
try {
await getScheduledTask(createdRule1.scheduled_task_id);
throw new Error('Should have removed scheduled task');
} catch (e) {
expect(e.meta.statusCode).to.eql(404);
}
break;
default:
throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);
}
});
it('should handle delete alert request appropriately when consumer is "alerts"', async () => {
const { body: createdRule1 } = await supertest
.post(`${getUrlPrefix(space.id)}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(
getTestRuleData({
rule_type_id: 'test.noop',
consumer: 'alerts',
})
)
.expect(200);
const response = await supertestWithoutAuth
.patch(`${getUrlPrefix(space.id)}/internal/alerting/rules/_bulk_delete`)
.set('kbn-xsrf', 'foo')
.send({ ids: [createdRule1.id] })
.auth(user.username, user.password);
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);
objectRemover.add(space.id, createdRule1.id, 'rule', 'alerting');
// Ensure task still exists
await getScheduledTask(createdRule1.scheduled_task_id);
break;
case 'global_read at space1':
expect(response.body).to.eql({
error: 'Forbidden',
message: 'Unauthorized to bulkDelete a "test.noop" rule by "alertsFixture"',
statusCode: 403,
});
expect(response.statusCode).to.eql(403);
objectRemover.add(space.id, createdRule1.id, 'rule', 'alerting');
// Ensure task still exists
await getScheduledTask(createdRule1.scheduled_task_id);
break;
case 'superuser at space1':
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(defaultSuccessfulResponse);
expect(response.statusCode).to.eql(200);
try {
await getScheduledTask(createdRule1.scheduled_task_id);
throw new Error('Should have removed scheduled task');
} catch (e) {
expect(e.meta.statusCode).to.eql(404);
}
break;
default:
throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);
}
});
it('should handle bulk delete of several rules ids appropriately based on ids', async () => {
const rules = await Promise.all(
Array.from({ length: 3 }).map(() =>
supertest
.post(`${getUrlPrefix(space.id)}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(getTestRuleData({ tags: ['multiple-rules-edit'] }))
.expect(200)
)
);
const response = await supertestWithoutAuth
.patch(`${getUrlPrefix(space.id)}/internal/alerting/rules/_bulk_delete`)
.set('kbn-xsrf', 'foo')
.send({ ids: rules.map((rule) => rule.body.id) })
.auth(user.username, user.password);
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);
await Promise.all(
rules.map((rule) => {
objectRemover.add(space.id, rule.body.id, 'rule', 'alerting');
return getScheduledTask(rule.body.scheduled_task_id);
})
);
break;
case 'global_read at space1':
expect(response.body).to.eql({
error: 'Forbidden',
message: 'Unauthorized to bulkDelete a "test.noop" rule for "alertsFixture"',
statusCode: 403,
});
expect(response.statusCode).to.eql(403);
await Promise.all(
rules.map((rule) => {
objectRemover.add(space.id, rule.body.id, 'rule', 'alerting');
return getScheduledTask(rule.body.scheduled_task_id);
})
);
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':
expect(response.body).to.eql({ ...defaultSuccessfulResponse, total: 3 });
expect(response.statusCode).to.eql(200);
for (const rule of rules) {
try {
await getScheduledTask(rule.body.scheduled_task_id);
throw new Error('Should have removed scheduled task');
} catch (e) {
expect(e.meta.statusCode).to.eql(404);
}
}
break;
default:
throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);
}
});
it('should handle bulk delete of several rules ids appropriately based on filter', async () => {
const rules = await Promise.all(
Array.from({ length: 3 }).map(() =>
supertest
.post(`${getUrlPrefix(space.id)}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(getTestRuleData({ tags: ['multiple-rules-delete'] }))
.expect(200)
)
);
const response = await supertestWithoutAuth
.patch(`${getUrlPrefix(space.id)}/internal/alerting/rules/_bulk_delete`)
.set('kbn-xsrf', 'foo')
.send({ filter: `alert.attributes.tags: "multiple-rules-delete"` })
.auth(user.username, user.password);
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);
await Promise.all(
rules.map((rule) => {
objectRemover.add(space.id, rule.body.id, 'rule', 'alerting');
return getScheduledTask(rule.body.scheduled_task_id);
})
);
break;
case 'global_read at space1':
expect(response.body).to.eql({
error: 'Forbidden',
message: 'Unauthorized to bulkDelete a "test.noop" rule for "alertsFixture"',
statusCode: 403,
});
expect(response.statusCode).to.eql(403);
await Promise.all(
rules.map((rule) => {
objectRemover.add(space.id, rule.body.id, 'rule', 'alerting');
return getScheduledTask(rule.body.scheduled_task_id);
})
);
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':
expect(response.body).to.eql({ ...defaultSuccessfulResponse, total: 3 });
expect(response.statusCode).to.eql(200);
for (const rule of rules) {
try {
await getScheduledTask(rule.body.scheduled_task_id);
throw new Error('Should have removed scheduled task');
} catch (e) {
expect(e.meta.statusCode).to.eql(404);
}
}
break;
default:
throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);
}
});
it('should not delete rule from another space', async () => {
const { body: createdRule } = await supertest
.post(`${getUrlPrefix('other')}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(getTestRuleData())
.expect(200);
const response = await supertestWithoutAuth
.patch(`${getUrlPrefix('other')}/internal/alerting/rules/_bulk_delete`)
.set('kbn-xsrf', 'foo')
.auth(user.username, user.password)
.send({ ids: [createdRule.id] });
switch (scenario.id) {
// This superuser has more privileges that we think
case 'superuser at space1':
expect(response.body).to.eql(defaultSuccessfulResponse);
expect(response.statusCode).to.eql(200);
try {
await getScheduledTask(createdRule.scheduled_task_id);
throw new Error('Should have removed scheduled task');
} catch (e) {
expect(e.meta.statusCode).to.eql(404);
}
break;
case 'global_read at space1':
expect(response.body).to.eql({
error: 'Forbidden',
message: 'Unauthorized to bulkDelete a "test.noop" rule for "alertsFixture"',
statusCode: 403,
});
expect(response.statusCode).to.eql(403);
objectRemover.add('other', createdRule.id, 'rule', 'alerting');
await getScheduledTask(createdRule.scheduled_task_id);
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);
expect(response.statusCode).to.eql(403);
objectRemover.add('other', createdRule.id, 'rule', 'alerting');
await getScheduledTask(createdRule.scheduled_task_id);
break;
default:
throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);
}
});
});
}
describe('Validation tests', () => {
const { user, space } = SuperuserAtSpace1;
it('should throw an error when bulk delete of rules when both ids and filter supplied in payload', async () => {
const { body: createdRule1 } = await supertest
.post(`${getUrlPrefix(space.id)}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(getTestRuleData({ tags: ['foo'] }))
.expect(200);
const response = await supertestWithoutAuth
.patch(`${getUrlPrefix(space.id)}/internal/alerting/rules/_bulk_delete`)
.set('kbn-xsrf', 'foo')
.send({ filter: 'fake_filter', ids: [createdRule1.id] })
.auth(user.username, user.password);
expect(response.statusCode).to.eql(400);
expect(response.body.message).to.eql(
"Both 'filter' and 'ids' are supplied. Define either 'ids' or 'filter' properties in method's arguments"
);
objectRemover.add(space.id, createdRule1.id, 'rule', 'alerting');
await getScheduledTask(createdRule1.scheduled_task_id);
});
it('should return an error if we pass more than 1000 ids', async () => {
const ids = [...Array(1001)].map((_, i) => `rule${i}`);
const response = await supertestWithoutAuth
.patch(`${getUrlPrefix(space.id)}/internal/alerting/rules/_bulk_delete`)
.set('kbn-xsrf', 'foo')
.send({ ids })
.auth(user.username, user.password);
expect(response.body).to.eql({
error: 'Bad Request',
message: '[request body.ids]: array size is [1001], but cannot be greater than [1000]',
statusCode: 400,
});
});
it('should return an error if we do not pass any arguments', async () => {
await supertest
.post(`${getUrlPrefix(space.id)}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(getTestRuleData())
.expect(200);
const response = await supertestWithoutAuth
.patch(`${getUrlPrefix(space.id)}/internal/alerting/rules/_bulk_delete`)
.set('kbn-xsrf', 'foo')
.send({})
.auth(user.username, user.password);
expect(response.body).to.eql({
error: 'Bad Request',
message: "Either 'ids' or 'filter' property in method's arguments should be provided",
statusCode: 400,
});
});
it('should return an error if we pass empty ids array', async () => {
const response = await supertestWithoutAuth
.patch(`${getUrlPrefix(space.id)}/internal/alerting/rules/_bulk_delete`)
.set('kbn-xsrf', 'foo')
.send({ ids: [] })
.auth(user.username, user.password);
expect(response.body).to.eql({
error: 'Bad Request',
message: '[request body.ids]: array size is [0], but cannot be smaller than [1]',
statusCode: 400,
});
});
it('should return an error if we pass empty string instead of fiter', async () => {
const response = await supertestWithoutAuth
.patch(`${getUrlPrefix(space.id)}/internal/alerting/rules/_bulk_delete`)
.set('kbn-xsrf', 'foo')
.send({ filter: '' })
.auth(user.username, user.password);
expect(response.body).to.eql({
error: 'Bad Request',
message: "Either 'ids' or 'filter' property in method's arguments should be provided",
statusCode: 400,
});
});
});
});
};

View file

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

View file

@ -196,7 +196,7 @@ const NoKibanaPrivilegesAtSpace1: NoKibanaPrivilegesAtSpace1 = {
interface SuperuserAtSpace1 extends Scenario {
id: 'superuser at space1';
}
const SuperuserAtSpace1: SuperuserAtSpace1 = {
export const SuperuserAtSpace1: SuperuserAtSpace1 = {
id: 'superuser at space1',
user: Superuser,
space: Space1,