mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[RAM] Add bulk action to Untrack selected alerts (#167579)
## Summary Part of #164059 <img width="301" alt="Screenshot 2023-09-28 at 5 38 45 PM" src="1b9ae224
-7dad-43d7-a930-adf9458e1613"> <img width="486" alt="Screenshot 2023-09-28 at 5 38 11 PM" src="82eeec3d
-af2c-4257-b78e-99aea5a6b66f"> This PR: - Moves the `setAlertStatusToUntracked` function from the `AlertsClient` into the `AlertsService`. This function doesn't actually need any Rule IDs to do what it's supposed to do, only indices and Alert UUIDs. Therefore, we want to make it possible to use outside of a created `AlertsClient`, which requires a Rule to initialize. - Creates a versioned internal API to bulk untrack a given set of `alertUuids` present on `indices`. Both of these pieces of information are readily available from the ECS fields sent to the alert table component, from where this bulk action will be called. - Switches the `setAlertStatusToUntracked` query to look for alert UUIDs instead of alert instance IDs. https://github.com/elastic/kibana/pull/164788 dealt with untracking alerts that were bound to a single rule at a time, but this PR could be untracking alerts generated by many different rules at once. Multiple rules may generate the same alert instance ID names with different UUIDs, so using UUID increases the specificity and prevents us from untracking alert instances that the user didn't intend. - Adds a `bulkUpdateState` method to the task scheduler. https://github.com/elastic/kibana/pull/164788 modified the `bulkDisable` method to clear untracked alerts from task states, but this new method allows us to untrack a given set of alert instances without disabling the task that generated them. #### Why omit rule ID from this API? The rule ID is technically readily available from the alert table, but it becomes redundant when we already have immediate access to the alert document's index. https://github.com/elastic/kibana/pull/164788 used the rule ID to get the `ruleTypeId` and turn this into a corresponding index, which we don't have to do anymore. Furthermore, it helps to omit the rule ID from the `updateByQuery` request, because the user can easily select alerts that were generated by a wide variety of different rules, and untrack them all at once. We could include the rule ID in a separate `should` query, but this adds needless complexity to the query. We do need to know the rule ID after performing `updateByQuery`, because it corresponds to the task state we want to modify, but it's easier to retrieve this using the same query params provided. ### Checklist Delete any items that are not applicable to this PR. - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [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 --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Jiawei Wu <jiawei.wu@cmd.com> Co-authored-by: Xavier Mouligneau <xavier.mouligneau@elastic.co>
This commit is contained in:
parent
e74e5e2bfc
commit
5b1ae6683f
57 changed files with 1647 additions and 163 deletions
|
@ -0,0 +1,12 @@
|
|||
/*
|
||||
* 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 { bulkUntrackBodySchema } from './schemas/latest';
|
||||
export { bulkUntrackBodySchema as bulkUntrackBodySchemaV1 } from './schemas/v1';
|
||||
|
||||
export type { BulkUntrackRequestBody } from './types/latest';
|
||||
export type { BulkUntrackRequestBody as BulkUntrackRequestBodyV1 } from './types/v1';
|
|
@ -0,0 +1,7 @@
|
|||
/*
|
||||
* 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 { bulkUntrackBodySchema } from './v1';
|
|
@ -0,0 +1,13 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { schema } from '@kbn/config-schema';
|
||||
|
||||
export const bulkUntrackBodySchema = schema.object({
|
||||
indices: schema.arrayOf(schema.string()),
|
||||
alert_uuids: schema.arrayOf(schema.string()),
|
||||
});
|
|
@ -0,0 +1,8 @@
|
|||
/*
|
||||
* 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 type { BulkUntrackRequestBody } from './v1';
|
|
@ -0,0 +1,10 @@
|
|||
/*
|
||||
* 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 type { TypeOf } from '@kbn/config-schema';
|
||||
import { bulkUntrackBodySchemaV1 } from '..';
|
||||
|
||||
export type BulkUntrackRequestBody = TypeOf<typeof bulkUntrackBodySchemaV1>;
|
|
@ -2331,53 +2331,6 @@ describe('Alerts Client', () => {
|
|||
expect(recoveredAlert.hit).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setAlertStatusToUntracked()', () => {
|
||||
test('should call updateByQuery on provided ruleIds', async () => {
|
||||
const alertsClient = new AlertsClient<{}, {}, {}, 'default', 'recovered'>(
|
||||
alertsClientParams
|
||||
);
|
||||
|
||||
const opts = {
|
||||
maxAlerts,
|
||||
ruleLabel: `test: rule-name`,
|
||||
flappingSettings: DEFAULT_FLAPPING_SETTINGS,
|
||||
activeAlertsFromState: {},
|
||||
recoveredAlertsFromState: {},
|
||||
};
|
||||
await alertsClient.initializeExecution(opts);
|
||||
|
||||
await alertsClient.setAlertStatusToUntracked(['test-index'], ['test-rule']);
|
||||
|
||||
expect(clusterClient.updateByQuery).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('should retry updateByQuery on failure', async () => {
|
||||
clusterClient.updateByQuery.mockResponseOnce({
|
||||
total: 10,
|
||||
updated: 8,
|
||||
});
|
||||
const alertsClient = new AlertsClient<{}, {}, {}, 'default', 'recovered'>(
|
||||
alertsClientParams
|
||||
);
|
||||
|
||||
const opts = {
|
||||
maxAlerts,
|
||||
ruleLabel: `test: rule-name`,
|
||||
flappingSettings: DEFAULT_FLAPPING_SETTINGS,
|
||||
activeAlertsFromState: {},
|
||||
recoveredAlertsFromState: {},
|
||||
};
|
||||
await alertsClient.initializeExecution(opts);
|
||||
|
||||
await alertsClient.setAlertStatusToUntracked(['test-index'], ['test-rule']);
|
||||
|
||||
expect(clusterClient.updateByQuery).toHaveBeenCalledTimes(2);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Attempt 1: Failed to untrack 2 of 10; indices test-index, ruleIds test-rule'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
|
@ -6,14 +6,7 @@
|
|||
*/
|
||||
|
||||
import { ElasticsearchClient } from '@kbn/core/server';
|
||||
import {
|
||||
ALERT_INSTANCE_ID,
|
||||
ALERT_RULE_UUID,
|
||||
ALERT_STATUS,
|
||||
ALERT_STATUS_UNTRACKED,
|
||||
ALERT_STATUS_ACTIVE,
|
||||
ALERT_UUID,
|
||||
} from '@kbn/rule-data-utils';
|
||||
import { ALERT_INSTANCE_ID, ALERT_RULE_UUID, ALERT_STATUS, ALERT_UUID } from '@kbn/rule-data-utils';
|
||||
import { chunk, flatMap, get, isEmpty, keys } from 'lodash';
|
||||
import { SearchRequest } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
|
||||
import type { Alert } from '@kbn/alerts-as-data-utils';
|
||||
|
@ -206,51 +199,6 @@ export class AlertsClient<
|
|||
return { hits, total };
|
||||
}
|
||||
|
||||
public async setAlertStatusToUntracked(indices: string[], ruleIds: string[]) {
|
||||
const esClient = await this.options.elasticsearchClientPromise;
|
||||
const terms: Array<{ term: Record<string, { value: string }> }> = ruleIds.map((ruleId) => ({
|
||||
term: {
|
||||
[ALERT_RULE_UUID]: { value: ruleId },
|
||||
},
|
||||
}));
|
||||
terms.push({
|
||||
term: {
|
||||
[ALERT_STATUS]: { value: ALERT_STATUS_ACTIVE },
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
// Retry this updateByQuery up to 3 times to make sure the number of documents
|
||||
// updated equals the number of documents matched
|
||||
for (let retryCount = 0; retryCount < 3; retryCount++) {
|
||||
const response = await esClient.updateByQuery({
|
||||
index: indices,
|
||||
allow_no_indices: true,
|
||||
body: {
|
||||
conflicts: 'proceed',
|
||||
script: {
|
||||
source: UNTRACK_UPDATE_PAINLESS_SCRIPT,
|
||||
lang: 'painless',
|
||||
},
|
||||
query: {
|
||||
bool: {
|
||||
must: terms,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (response.total === response.updated) break;
|
||||
this.options.logger.warn(
|
||||
`Attempt ${retryCount + 1}: Failed to untrack ${
|
||||
(response.total ?? 0) - (response.updated ?? 0)
|
||||
} of ${response.total}; indices ${indices}, ruleIds ${ruleIds}`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
this.options.logger.error(`Error marking ${ruleIds} as untracked - ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public report(
|
||||
alert: ReportedAlert<
|
||||
AlertData,
|
||||
|
@ -621,11 +569,3 @@ export class AlertsClient<
|
|||
return this._isUsingDataStreams;
|
||||
}
|
||||
}
|
||||
|
||||
const UNTRACK_UPDATE_PAINLESS_SCRIPT = `
|
||||
// Certain rule types don't flatten their AAD values, apply the ALERT_STATUS key to them directly
|
||||
if (!ctx._source.containsKey('${ALERT_STATUS}') || ctx._source['${ALERT_STATUS}'].empty) {
|
||||
ctx._source.${ALERT_STATUS} = '${ALERT_STATUS_UNTRACKED}';
|
||||
} else {
|
||||
ctx._source['${ALERT_STATUS}'] = '${ALERT_STATUS_UNTRACKED}'
|
||||
}`;
|
||||
|
|
|
@ -81,7 +81,6 @@ export interface IAlertsClient<
|
|||
alertsToReturn: Record<string, RawAlertInstance>;
|
||||
recoveredAlertsToReturn: Record<string, RawAlertInstance>;
|
||||
};
|
||||
setAlertStatusToUntracked(indices: string[], ruleIds: string[]): Promise<void>;
|
||||
factory(): PublicAlertFactory<
|
||||
State,
|
||||
Context,
|
||||
|
|
|
@ -12,6 +12,7 @@ const creatAlertsServiceMock = () => {
|
|||
isInitialized: jest.fn(),
|
||||
getContextInitializationPromise: jest.fn(),
|
||||
createAlertsClient: jest.fn(),
|
||||
setAlertsToUntracked: jest.fn(),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
|
|
@ -44,6 +44,7 @@ import {
|
|||
import type { LegacyAlertsClientParams, AlertRuleData } from '../alerts_client';
|
||||
import { AlertsClient } from '../alerts_client';
|
||||
import { IAlertsClient } from '../alerts_client/types';
|
||||
import { setAlertsToUntracked, SetAlertsToUntrackedOpts } from './lib/set_alerts_to_untracked';
|
||||
|
||||
export const TOTAL_FIELDS_LIMIT = 2500;
|
||||
const LEGACY_ALERT_CONTEXT = 'legacy-alert';
|
||||
|
@ -458,4 +459,12 @@ export class AlertsService implements IAlertsService {
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async setAlertsToUntracked(opts: SetAlertsToUntrackedOpts) {
|
||||
return setAlertsToUntracked({
|
||||
logger: this.options.logger,
|
||||
esClient: await this.options.elasticsearchClientPromise,
|
||||
...opts,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,337 @@
|
|||
/*
|
||||
* 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 {
|
||||
ElasticsearchClientMock,
|
||||
elasticsearchServiceMock,
|
||||
loggingSystemMock,
|
||||
} from '@kbn/core/server/mocks';
|
||||
import { setAlertsToUntracked } from './set_alerts_to_untracked';
|
||||
|
||||
let clusterClient: ElasticsearchClientMock;
|
||||
let logger: ReturnType<typeof loggingSystemMock['createLogger']>;
|
||||
|
||||
describe('setAlertsToUntracked()', () => {
|
||||
beforeEach(() => {
|
||||
logger = loggingSystemMock.createLogger();
|
||||
clusterClient = elasticsearchServiceMock.createClusterClient().asInternalUser;
|
||||
clusterClient.search.mockResponse({
|
||||
took: 1,
|
||||
timed_out: false,
|
||||
_shards: {
|
||||
total: 1,
|
||||
successful: 1,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
},
|
||||
hits: {
|
||||
hits: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
test('should call updateByQuery on provided ruleIds', async () => {
|
||||
await setAlertsToUntracked({
|
||||
logger,
|
||||
esClient: clusterClient,
|
||||
indices: ['test-index'],
|
||||
ruleIds: ['test-rule'],
|
||||
});
|
||||
|
||||
expect(clusterClient.updateByQuery).toHaveBeenCalledTimes(1);
|
||||
expect(clusterClient.updateByQuery.mock.lastCall).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"allow_no_indices": true,
|
||||
"body": Object {
|
||||
"conflicts": "proceed",
|
||||
"query": Object {
|
||||
"bool": Object {
|
||||
"must": Array [
|
||||
Object {
|
||||
"term": Object {
|
||||
"kibana.alert.status": Object {
|
||||
"value": "active",
|
||||
},
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"bool": Object {
|
||||
"should": Array [
|
||||
Object {
|
||||
"term": Object {
|
||||
"kibana.alert.rule.uuid": Object {
|
||||
"value": "test-rule",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"bool": Object {
|
||||
"should": Array [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
"script": Object {
|
||||
"lang": "painless",
|
||||
"source": "
|
||||
if (!ctx._source.containsKey('kibana.alert.status') || ctx._source['kibana.alert.status'].empty) {
|
||||
ctx._source.kibana.alert.status = 'untracked';
|
||||
} else {
|
||||
ctx._source['kibana.alert.status'] = 'untracked'
|
||||
}",
|
||||
},
|
||||
},
|
||||
"index": Array [
|
||||
"test-index",
|
||||
],
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
test('should call updateByQuery on provided alertUuids', async () => {
|
||||
await setAlertsToUntracked({
|
||||
logger,
|
||||
esClient: clusterClient,
|
||||
indices: ['test-index'],
|
||||
alertUuids: ['test-alert'],
|
||||
});
|
||||
|
||||
expect(clusterClient.updateByQuery).toHaveBeenCalledTimes(1);
|
||||
expect(clusterClient.updateByQuery.mock.lastCall).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"allow_no_indices": true,
|
||||
"body": Object {
|
||||
"conflicts": "proceed",
|
||||
"query": Object {
|
||||
"bool": Object {
|
||||
"must": Array [
|
||||
Object {
|
||||
"term": Object {
|
||||
"kibana.alert.status": Object {
|
||||
"value": "active",
|
||||
},
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"bool": Object {
|
||||
"should": Array [],
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"bool": Object {
|
||||
"should": Array [
|
||||
Object {
|
||||
"term": Object {
|
||||
"kibana.alert.uuid": Object {
|
||||
"value": "test-alert",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
"script": Object {
|
||||
"lang": "painless",
|
||||
"source": "
|
||||
if (!ctx._source.containsKey('kibana.alert.status') || ctx._source['kibana.alert.status'].empty) {
|
||||
ctx._source.kibana.alert.status = 'untracked';
|
||||
} else {
|
||||
ctx._source['kibana.alert.status'] = 'untracked'
|
||||
}",
|
||||
},
|
||||
},
|
||||
"index": Array [
|
||||
"test-index",
|
||||
],
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
test('should retry updateByQuery on failure', async () => {
|
||||
clusterClient.updateByQuery.mockResponseOnce({
|
||||
total: 10,
|
||||
updated: 8,
|
||||
});
|
||||
|
||||
await setAlertsToUntracked({
|
||||
logger,
|
||||
esClient: clusterClient,
|
||||
indices: ['test-index'],
|
||||
ruleIds: ['test-rule'],
|
||||
});
|
||||
|
||||
expect(clusterClient.updateByQuery).toHaveBeenCalledTimes(2);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Attempt 1: Failed to untrack 2 of 10; indices test-index, ruleIds test-rule'
|
||||
);
|
||||
});
|
||||
|
||||
describe('ensureAuthorized', () => {
|
||||
test('should fail on siem consumer', async () => {
|
||||
clusterClient.search.mockResponseOnce({
|
||||
took: 1,
|
||||
timed_out: false,
|
||||
_shards: {
|
||||
total: 1,
|
||||
successful: 1,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
},
|
||||
hits: {
|
||||
hits: [],
|
||||
},
|
||||
aggregations: {
|
||||
ruleTypeIds: {
|
||||
buckets: [
|
||||
{
|
||||
key: 'some rule type',
|
||||
consumers: {
|
||||
buckets: [
|
||||
{
|
||||
key: 'not siem',
|
||||
},
|
||||
{
|
||||
key: 'definitely not siem',
|
||||
},
|
||||
{
|
||||
key: 'hey guess what still not siem',
|
||||
},
|
||||
{
|
||||
key: 'siem',
|
||||
},
|
||||
{
|
||||
key: 'uh oh was that siem',
|
||||
},
|
||||
{
|
||||
key: 'not good',
|
||||
},
|
||||
{
|
||||
key: 'this is gonna fail',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
setAlertsToUntracked({
|
||||
logger,
|
||||
esClient: clusterClient,
|
||||
indices: ['test-index'],
|
||||
ruleIds: ['test-rule'],
|
||||
ensureAuthorized: () => Promise.resolve(),
|
||||
})
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(`"Untracking Security alerts is not permitted"`);
|
||||
});
|
||||
|
||||
test('should fail on unauthorized consumer', async () => {
|
||||
clusterClient.search.mockResponseOnce({
|
||||
took: 1,
|
||||
timed_out: false,
|
||||
_shards: {
|
||||
total: 1,
|
||||
successful: 1,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
},
|
||||
hits: {
|
||||
hits: [],
|
||||
},
|
||||
aggregations: {
|
||||
ruleTypeIds: {
|
||||
buckets: [
|
||||
{
|
||||
key: 'some rule',
|
||||
consumers: {
|
||||
buckets: [
|
||||
{
|
||||
key: 'authorized',
|
||||
},
|
||||
{
|
||||
key: 'unauthorized',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
setAlertsToUntracked({
|
||||
logger,
|
||||
esClient: clusterClient,
|
||||
indices: ['test-index'],
|
||||
ruleIds: ['test-rule'],
|
||||
ensureAuthorized: async ({ consumer }) => {
|
||||
if (consumer === 'unauthorized') throw new Error('Unauthorized consumer');
|
||||
},
|
||||
})
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(`"Unauthorized consumer"`);
|
||||
});
|
||||
});
|
||||
|
||||
test('should succeed when all consumers are authorized', async () => {
|
||||
clusterClient.search.mockResponseOnce({
|
||||
took: 1,
|
||||
timed_out: false,
|
||||
_shards: {
|
||||
total: 1,
|
||||
successful: 1,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
},
|
||||
hits: {
|
||||
hits: [],
|
||||
},
|
||||
aggregations: {
|
||||
ruleTypeIds: {
|
||||
buckets: [
|
||||
{
|
||||
key: 'some rule',
|
||||
consumers: {
|
||||
buckets: [
|
||||
{
|
||||
key: 'authorized',
|
||||
},
|
||||
{
|
||||
key: 'still authorized',
|
||||
},
|
||||
{
|
||||
key: 'even this one is authorized',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
setAlertsToUntracked({
|
||||
logger,
|
||||
esClient: clusterClient,
|
||||
indices: ['test-index'],
|
||||
ruleIds: ['test-rule'],
|
||||
ensureAuthorized: async ({ consumer }) => {
|
||||
if (consumer === 'unauthorized') throw new Error('Unauthorized consumer');
|
||||
},
|
||||
})
|
||||
).resolves;
|
||||
});
|
||||
});
|
|
@ -0,0 +1,191 @@
|
|||
/*
|
||||
* 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 { isEmpty } from 'lodash';
|
||||
import { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
|
||||
import { Logger } from '@kbn/logging';
|
||||
import {
|
||||
ALERT_RULE_CONSUMER,
|
||||
ALERT_RULE_TYPE_ID,
|
||||
ALERT_RULE_UUID,
|
||||
ALERT_STATUS,
|
||||
ALERT_STATUS_ACTIVE,
|
||||
ALERT_STATUS_UNTRACKED,
|
||||
ALERT_UUID,
|
||||
} from '@kbn/rule-data-utils';
|
||||
|
||||
export interface SetAlertsToUntrackedOpts {
|
||||
indices: string[];
|
||||
ruleIds?: string[];
|
||||
alertUuids?: string[];
|
||||
ensureAuthorized?: (opts: { ruleTypeId: string; consumer: string }) => Promise<unknown>;
|
||||
}
|
||||
|
||||
type UntrackedAlertsResult = Array<{ [ALERT_RULE_UUID]: string; [ALERT_UUID]: string }>;
|
||||
interface ConsumersAndRuleTypesAggregation {
|
||||
ruleTypeIds: {
|
||||
buckets: Array<{
|
||||
key: string;
|
||||
consumers: {
|
||||
buckets: Array<{ key: string }>;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
export async function setAlertsToUntracked({
|
||||
logger,
|
||||
esClient,
|
||||
indices,
|
||||
ruleIds = [],
|
||||
alertUuids = [], // OPTIONAL - If no alertUuids are passed, untrack ALL ids by default,
|
||||
ensureAuthorized,
|
||||
}: {
|
||||
logger: Logger;
|
||||
esClient: ElasticsearchClient;
|
||||
} & SetAlertsToUntrackedOpts): Promise<UntrackedAlertsResult> {
|
||||
if (isEmpty(ruleIds) && isEmpty(alertUuids))
|
||||
throw new Error('Must provide either ruleIds or alertUuids');
|
||||
|
||||
const shouldMatchRules: Array<{ term: Record<string, { value: string }> }> = ruleIds.map(
|
||||
(ruleId) => ({
|
||||
term: {
|
||||
[ALERT_RULE_UUID]: { value: ruleId },
|
||||
},
|
||||
})
|
||||
);
|
||||
const shouldMatchAlerts: Array<{ term: Record<string, { value: string }> }> = alertUuids.map(
|
||||
(alertId) => ({
|
||||
term: {
|
||||
[ALERT_UUID]: { value: alertId },
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const statusTerms: Array<{ term: Record<string, { value: string }> }> = [
|
||||
{
|
||||
term: {
|
||||
[ALERT_STATUS]: { value: ALERT_STATUS_ACTIVE },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const must = [
|
||||
...statusTerms,
|
||||
{
|
||||
bool: {
|
||||
should: shouldMatchRules,
|
||||
},
|
||||
},
|
||||
{
|
||||
bool: {
|
||||
should: shouldMatchAlerts,
|
||||
// If this is empty, ES will default to minimum_should_match: 0
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (ensureAuthorized) {
|
||||
// Fetch all rule type IDs and rule consumers, then run the provided ensureAuthorized check for each of them
|
||||
const response = await esClient.search<never, ConsumersAndRuleTypesAggregation>({
|
||||
index: indices,
|
||||
allow_no_indices: true,
|
||||
body: {
|
||||
size: 0,
|
||||
query: {
|
||||
bool: {
|
||||
must,
|
||||
},
|
||||
},
|
||||
aggs: {
|
||||
ruleTypeIds: {
|
||||
terms: { field: ALERT_RULE_TYPE_ID },
|
||||
aggs: { consumers: { terms: { field: ALERT_RULE_CONSUMER } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const ruleTypeIdBuckets = response.aggregations?.ruleTypeIds.buckets;
|
||||
if (!ruleTypeIdBuckets) throw new Error('Unable to fetch ruleTypeIds for authorization');
|
||||
for (const {
|
||||
key: ruleTypeId,
|
||||
consumers: { buckets: consumerBuckets },
|
||||
} of ruleTypeIdBuckets) {
|
||||
const consumers = consumerBuckets.map((b) => b.key);
|
||||
for (const consumer of consumers) {
|
||||
if (consumer === 'siem') throw new Error('Untracking Security alerts is not permitted');
|
||||
await ensureAuthorized({ ruleTypeId, consumer });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Retry this updateByQuery up to 3 times to make sure the number of documents
|
||||
// updated equals the number of documents matched
|
||||
let total = 0;
|
||||
for (let retryCount = 0; retryCount < 3; retryCount++) {
|
||||
const response = await esClient.updateByQuery({
|
||||
index: indices,
|
||||
allow_no_indices: true,
|
||||
body: {
|
||||
conflicts: 'proceed',
|
||||
script: {
|
||||
source: UNTRACK_UPDATE_PAINLESS_SCRIPT,
|
||||
lang: 'painless',
|
||||
},
|
||||
query: {
|
||||
bool: {
|
||||
must,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (total === 0 && response.total === 0)
|
||||
throw new Error('No active alerts matched the query');
|
||||
if (response.total) total = response.total;
|
||||
if (response.total === response.updated) break;
|
||||
logger.warn(
|
||||
`Attempt ${retryCount + 1}: Failed to untrack ${
|
||||
(response.total ?? 0) - (response.updated ?? 0)
|
||||
} of ${response.total}; indices ${indices}, ${ruleIds ? 'ruleIds' : 'alertUuids'} ${
|
||||
ruleIds ? ruleIds : alertUuids
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch and return updated rule and alert instance UUIDs
|
||||
const searchResponse = await esClient.search({
|
||||
index: indices,
|
||||
allow_no_indices: true,
|
||||
body: {
|
||||
_source: [ALERT_RULE_UUID, ALERT_UUID],
|
||||
size: total,
|
||||
query: {
|
||||
bool: {
|
||||
must,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return searchResponse.hits.hits.map((hit) => hit._source) as UntrackedAlertsResult;
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`Error marking ${ruleIds ? 'ruleIds' : 'alertUuids'} ${
|
||||
ruleIds ? ruleIds : alertUuids
|
||||
} as untracked - ${err.message}`
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Certain rule types don't flatten their AAD values, apply the ALERT_STATUS key to them directly
|
||||
const UNTRACK_UPDATE_PAINLESS_SCRIPT = `
|
||||
if (!ctx._source.containsKey('${ALERT_STATUS}') || ctx._source['${ALERT_STATUS}'].empty) {
|
||||
ctx._source.${ALERT_STATUS} = '${ALERT_STATUS_UNTRACKED}';
|
||||
} else {
|
||||
ctx._source['${ALERT_STATUS}'] = '${ALERT_STATUS_UNTRACKED}'
|
||||
}`;
|
|
@ -36,6 +36,7 @@ import { transformRuleAttributesToRuleDomain, transformRuleDomainToRule } from '
|
|||
import { ruleDomainSchema } from '../../schemas';
|
||||
import type { RuleParams, RuleDomain } from '../../types';
|
||||
import type { RawRule, SanitizedRule } from '../../../../types';
|
||||
import { untrackRuleAlerts } from '../../../../rules_client/lib';
|
||||
|
||||
export const bulkDeleteRules = async <Params extends RuleParams>(
|
||||
context: RulesClientContext,
|
||||
|
@ -176,6 +177,10 @@ const bulkDeleteWithOCC = async (
|
|||
}
|
||||
);
|
||||
|
||||
for (const { id, attributes } of rulesToDelete) {
|
||||
await untrackRuleAlerts(context, id, attributes as RuleAttributes);
|
||||
}
|
||||
|
||||
const result = await withSpan(
|
||||
{ name: 'unsecuredSavedObjectsClient.bulkDelete', type: 'rules' },
|
||||
() =>
|
||||
|
|
|
@ -0,0 +1,232 @@
|
|||
/*
|
||||
* 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/rules_client';
|
||||
import { savedObjectsClientMock, savedObjectsRepositoryMock } from '@kbn/core/server/mocks';
|
||||
import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks';
|
||||
import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks';
|
||||
import { actionsAuthorizationMock } from '@kbn/actions-plugin/server/mocks';
|
||||
import { ActionsAuthorization } from '@kbn/actions-plugin/server';
|
||||
import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks';
|
||||
import { loggerMock } from '@kbn/logging-mocks';
|
||||
import { ruleTypeRegistryMock } from '../../../../rule_type_registry.mock';
|
||||
import { alertingAuthorizationMock } from '../../../../authorization/alerting_authorization.mock';
|
||||
import { AlertingAuthorization } from '../../../../authorization/alerting_authorization';
|
||||
import { alertsServiceMock } from '../../../../alerts_service/alerts_service.mock';
|
||||
import { ALERT_RULE_UUID, ALERT_UUID } from '@kbn/rule-data-utils';
|
||||
import { ConcreteTaskInstance, TaskStatus } from '@kbn/task-manager-plugin/server';
|
||||
|
||||
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 internalSavedObjectsRepository = savedObjectsRepositoryMock.create();
|
||||
|
||||
const alertsService = alertsServiceMock.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,
|
||||
internalSavedObjectsRepository,
|
||||
encryptedSavedObjectsClient: encryptedSavedObjects,
|
||||
getActionsClient: jest.fn(),
|
||||
getEventLogClient: jest.fn(),
|
||||
kibanaVersion,
|
||||
auditLogger,
|
||||
maxScheduledPerMinute: 10000,
|
||||
minimumScheduleInterval: { value: '1m', enforce: false },
|
||||
isAuthenticationTypeAPIKey: jest.fn(),
|
||||
getAuthenticationAPIKey: jest.fn(),
|
||||
getAlertIndicesAlias: jest.fn(),
|
||||
alertsService,
|
||||
};
|
||||
|
||||
describe('bulkUntrackAlerts()', () => {
|
||||
let rulesClient: RulesClient;
|
||||
beforeEach(async () => {
|
||||
rulesClient = new RulesClient(rulesClientParams);
|
||||
});
|
||||
|
||||
it('should untrack alert documents and update task states', async () => {
|
||||
alertsService.setAlertsToUntracked.mockResolvedValueOnce([
|
||||
{
|
||||
[ALERT_RULE_UUID]:
|
||||
'did you know that you can put whatever you want into these mocked values',
|
||||
[ALERT_UUID]: "it's true",
|
||||
},
|
||||
]);
|
||||
|
||||
await rulesClient.bulkUntrackAlerts({
|
||||
indices: [
|
||||
'she had them apple bottom jeans (jeans)',
|
||||
'boots with the fur (with the fur)',
|
||||
'the whole club was lookin at her',
|
||||
'she hit the floor (she hit the floor)',
|
||||
'next thing you know',
|
||||
'shawty got low, low, low, low, low, low, low, low',
|
||||
],
|
||||
alertUuids: [
|
||||
'you wake up late for school, man, you dont wanna GO',
|
||||
'you ask your mom, please? but she still says NO',
|
||||
'you missed two classes and no homeWORK',
|
||||
'but your teacher preaches class like youre some kinda JERK',
|
||||
'you gotta fight',
|
||||
'for your right',
|
||||
'to paaaaaaaaaarty',
|
||||
],
|
||||
});
|
||||
|
||||
expect(alertsService.setAlertsToUntracked).toHaveBeenCalledTimes(1);
|
||||
expect(taskManager.bulkUpdateState).toHaveBeenCalledWith(
|
||||
['did you know that you can put whatever you want into these mocked values'],
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('should remove provided uuids from task state', async () => {
|
||||
const mockTaskId = 'task';
|
||||
const mockAlertUuid = 'alert';
|
||||
|
||||
const trackedAlertsNotToRemove = {
|
||||
"we're no strangers to love": { alertUuid: 'you know the rules and so do i' },
|
||||
"a full commitment's what i'm thinkin' of": {
|
||||
alertUuid: "you wouldn't get this from any other guy",
|
||||
},
|
||||
"i just wanna tell you how i'm feelin'": { alertUuid: 'got to make you understand' },
|
||||
'never gonna give you up': { alertUuid: 'never gonna let you down' },
|
||||
'never gonna run around and desert you': { alertUuid: 'never gonna make you cry' },
|
||||
'never gonna say goodbye': { alertUuid: 'never gonna tell a lie and hurt you' },
|
||||
};
|
||||
|
||||
const mockDate = new Date('2023-10-03T16:00:15.523Z');
|
||||
|
||||
const initialTask: ConcreteTaskInstance = {
|
||||
id: mockTaskId,
|
||||
state: {
|
||||
alertTypeState: {
|
||||
trackedAlerts: {
|
||||
removeMe: { alertUuid: mockAlertUuid },
|
||||
...trackedAlertsNotToRemove,
|
||||
},
|
||||
alertInstances: {
|
||||
removeMe: { alertUuid: mockAlertUuid },
|
||||
...trackedAlertsNotToRemove,
|
||||
},
|
||||
},
|
||||
},
|
||||
scheduledAt: mockDate,
|
||||
runAt: mockDate,
|
||||
startedAt: mockDate,
|
||||
retryAt: mockDate,
|
||||
ownerId: 'somebody',
|
||||
taskType: "once told me the world was gonna roll me i ain't the sharpest tool in the shed",
|
||||
params: {},
|
||||
attempts: 0,
|
||||
status: TaskStatus.Idle,
|
||||
};
|
||||
|
||||
taskManager.bulkUpdateState.mockImplementationOnce(async (taskIds, updater) => ({
|
||||
errors: [],
|
||||
tasks: [{ ...initialTask, state: updater(initialTask.state, taskIds[0]) }],
|
||||
}));
|
||||
|
||||
alertsService.setAlertsToUntracked.mockResolvedValueOnce([
|
||||
{
|
||||
[ALERT_RULE_UUID]: mockTaskId,
|
||||
[ALERT_UUID]: mockAlertUuid,
|
||||
},
|
||||
]);
|
||||
|
||||
await rulesClient.bulkUntrackAlerts({
|
||||
indices: ["honestly who cares we're not even testing the index right now"],
|
||||
alertUuids: [mockAlertUuid],
|
||||
});
|
||||
|
||||
const bulkUntrackResults = taskManager.bulkUpdateState.mock.results;
|
||||
const lastBulkUntrackResult = await bulkUntrackResults[bulkUntrackResults.length - 1].value;
|
||||
expect(lastBulkUntrackResult).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"errors": Array [],
|
||||
"tasks": Array [
|
||||
Object {
|
||||
"attempts": 0,
|
||||
"id": "task",
|
||||
"ownerId": "somebody",
|
||||
"params": Object {},
|
||||
"retryAt": 2023-10-03T16:00:15.523Z,
|
||||
"runAt": 2023-10-03T16:00:15.523Z,
|
||||
"scheduledAt": 2023-10-03T16:00:15.523Z,
|
||||
"startedAt": 2023-10-03T16:00:15.523Z,
|
||||
"state": Object {
|
||||
"alertInstances": Object {},
|
||||
"alertTypeState": Object {
|
||||
"alertInstances": Object {
|
||||
"a full commitment's what i'm thinkin' of": Object {
|
||||
"alertUuid": "you wouldn't get this from any other guy",
|
||||
},
|
||||
"i just wanna tell you how i'm feelin'": Object {
|
||||
"alertUuid": "got to make you understand",
|
||||
},
|
||||
"never gonna give you up": Object {
|
||||
"alertUuid": "never gonna let you down",
|
||||
},
|
||||
"never gonna run around and desert you": Object {
|
||||
"alertUuid": "never gonna make you cry",
|
||||
},
|
||||
"never gonna say goodbye": Object {
|
||||
"alertUuid": "never gonna tell a lie and hurt you",
|
||||
},
|
||||
"removeMe": Object {
|
||||
"alertUuid": "alert",
|
||||
},
|
||||
"we're no strangers to love": Object {
|
||||
"alertUuid": "you know the rules and so do i",
|
||||
},
|
||||
},
|
||||
"trackedAlerts": Object {
|
||||
"a full commitment's what i'm thinkin' of": Object {
|
||||
"alertUuid": "you wouldn't get this from any other guy",
|
||||
},
|
||||
"i just wanna tell you how i'm feelin'": Object {
|
||||
"alertUuid": "got to make you understand",
|
||||
},
|
||||
"never gonna give you up": Object {
|
||||
"alertUuid": "never gonna let you down",
|
||||
},
|
||||
"never gonna run around and desert you": Object {
|
||||
"alertUuid": "never gonna make you cry",
|
||||
},
|
||||
"never gonna say goodbye": Object {
|
||||
"alertUuid": "never gonna tell a lie and hurt you",
|
||||
},
|
||||
"we're no strangers to love": Object {
|
||||
"alertUuid": "you know the rules and so do i",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"status": "idle",
|
||||
"taskType": "once told me the world was gonna roll me i ain't the sharpest tool in the shed",
|
||||
},
|
||||
],
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,106 @@
|
|||
/*
|
||||
* 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 { omitBy } from 'lodash';
|
||||
import Boom from '@hapi/boom';
|
||||
import { withSpan } from '@kbn/apm-utils';
|
||||
import { ALERT_RULE_UUID, ALERT_UUID } from '@kbn/rule-data-utils';
|
||||
import { bulkUntrackBodySchema } from './schemas';
|
||||
import type { BulkUntrackBody } from './types';
|
||||
import { WriteOperations, AlertingAuthorizationEntity } from '../../../../authorization';
|
||||
import { retryIfConflicts } from '../../../../lib/retry_if_conflicts';
|
||||
import { ruleAuditEvent, RuleAuditAction } from '../../../../rules_client/common/audit_events';
|
||||
import { RulesClientContext } from '../../../../rules_client/types';
|
||||
|
||||
export type { BulkUntrackBody };
|
||||
|
||||
export async function bulkUntrackAlerts(
|
||||
context: RulesClientContext,
|
||||
params: BulkUntrackBody
|
||||
): Promise<void> {
|
||||
try {
|
||||
bulkUntrackBodySchema.validate(params);
|
||||
} catch (error) {
|
||||
throw Boom.badRequest(`Failed to validate params: ${error.message}`);
|
||||
}
|
||||
|
||||
return await retryIfConflicts(
|
||||
context.logger,
|
||||
`rulesClient.bulkUntrack('${params.alertUuids}')`,
|
||||
async () => await bulkUntrackAlertsWithOCC(context, params)
|
||||
);
|
||||
}
|
||||
|
||||
async function bulkUntrackAlertsWithOCC(
|
||||
context: RulesClientContext,
|
||||
{ indices, alertUuids }: BulkUntrackBody
|
||||
) {
|
||||
try {
|
||||
if (!context.alertsService) throw new Error('unable to access alertsService');
|
||||
const result = await context.alertsService.setAlertsToUntracked({
|
||||
indices,
|
||||
alertUuids,
|
||||
ensureAuthorized: async ({
|
||||
ruleTypeId,
|
||||
consumer,
|
||||
}: {
|
||||
ruleTypeId: string;
|
||||
consumer: string;
|
||||
}) =>
|
||||
await withSpan({ name: 'authorization.ensureAuthorized', type: 'alerts' }, () =>
|
||||
context.authorization.ensureAuthorized({
|
||||
ruleTypeId,
|
||||
consumer,
|
||||
operation: WriteOperations.Update,
|
||||
entity: AlertingAuthorizationEntity.Alert,
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
// Clear alert instances from their corresponding tasks so that they can remain untracked
|
||||
const taskIds = [...new Set(result.map((doc) => doc[ALERT_RULE_UUID]))];
|
||||
await context.taskManager.bulkUpdateState(taskIds, (state, id) => {
|
||||
try {
|
||||
const uuidsToClear = result
|
||||
.filter((doc) => doc[ALERT_RULE_UUID] === id)
|
||||
.map((doc) => doc[ALERT_UUID]);
|
||||
const alertTypeState = {
|
||||
...state.alertTypeState,
|
||||
trackedAlerts: omitBy(state.alertTypeState.trackedAlerts, ({ alertUuid }) =>
|
||||
uuidsToClear.includes(alertUuid)
|
||||
),
|
||||
};
|
||||
const alertInstances = omitBy(state.alertInstances, ({ meta: { uuid } }) =>
|
||||
uuidsToClear.includes(uuid)
|
||||
);
|
||||
return {
|
||||
...state,
|
||||
alertTypeState,
|
||||
alertInstances,
|
||||
};
|
||||
} catch (e) {
|
||||
context.logger.error(`Failed to untrack alerts in task ID ${id}`);
|
||||
return state;
|
||||
}
|
||||
});
|
||||
|
||||
context.auditLogger?.log(
|
||||
ruleAuditEvent({
|
||||
action: RuleAuditAction.UNTRACK_ALERT,
|
||||
outcome: 'success',
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
context.auditLogger?.log(
|
||||
ruleAuditEvent({
|
||||
action: RuleAuditAction.UNTRACK_ALERT,
|
||||
error,
|
||||
})
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
/*
|
||||
* 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';
|
||||
|
||||
export const bulkUntrackBodySchema = schema.object({
|
||||
indices: schema.arrayOf(schema.string()),
|
||||
alertUuids: schema.arrayOf(schema.string()),
|
||||
});
|
|
@ -0,0 +1,7 @@
|
|||
/*
|
||||
* 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 { bulkUntrackBodySchema } from './bulk_untrack_body_schema';
|
|
@ -0,0 +1,11 @@
|
|||
/*
|
||||
* 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 { TypeOf } from '@kbn/config-schema';
|
||||
import { bulkUntrackBodySchema } from '../schemas';
|
||||
|
||||
export type BulkUntrackBody = TypeOf<typeof bulkUntrackBodySchema>;
|
|
@ -0,0 +1,8 @@
|
|||
/*
|
||||
* 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 type { BulkUntrackBody } from './bulk_untrack_body';
|
|
@ -48,6 +48,7 @@ import { getFlappingSettingsRoute } from './get_flapping_settings';
|
|||
import { updateFlappingSettingsRoute } from './update_flapping_settings';
|
||||
import { getRuleTagsRoute } from './get_rule_tags';
|
||||
import { getScheduleFrequencyRoute } from './rule/apis/get_schedule_frequency';
|
||||
import { bulkUntrackAlertRoute } from './rule/apis/bulk_untrack';
|
||||
|
||||
import { createMaintenanceWindowRoute } from './maintenance_window/apis/create/create_maintenance_window_route';
|
||||
import { getMaintenanceWindowRoute } from './maintenance_window/apis/get/get_maintenance_window_route';
|
||||
|
@ -131,4 +132,5 @@ export function defineRoutes(opts: RouteOptions) {
|
|||
registerFieldsRoute(router, licenseState);
|
||||
bulkGetMaintenanceWindowRoute(router, licenseState);
|
||||
getScheduleFrequencyRoute(router, licenseState);
|
||||
bulkUntrackAlertRoute(router, licenseState);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import { IRouter } from '@kbn/core/server';
|
||||
import {
|
||||
BulkUntrackRequestBodyV1,
|
||||
bulkUntrackBodySchemaV1,
|
||||
} from '../../../../../common/routes/rule/apis/bulk_untrack';
|
||||
import { transformRequestBodyToApplicationV1 } from './transforms';
|
||||
import { ILicenseState, RuleTypeDisabledError } from '../../../../lib';
|
||||
import { verifyAccessAndContext } from '../../../lib';
|
||||
import { AlertingRequestHandlerContext, INTERNAL_BASE_ALERTING_API_PATH } from '../../../../types';
|
||||
|
||||
export const bulkUntrackAlertRoute = (
|
||||
router: IRouter<AlertingRequestHandlerContext>,
|
||||
licenseState: ILicenseState
|
||||
) => {
|
||||
router.post(
|
||||
{
|
||||
path: `${INTERNAL_BASE_ALERTING_API_PATH}/alerts/_bulk_untrack`,
|
||||
validate: {
|
||||
body: bulkUntrackBodySchemaV1,
|
||||
},
|
||||
},
|
||||
router.handleLegacyErrors(
|
||||
verifyAccessAndContext(licenseState, async function (context, req, res) {
|
||||
const rulesClient = (await context.alerting).getRulesClient();
|
||||
const body: BulkUntrackRequestBodyV1 = req.body;
|
||||
try {
|
||||
await rulesClient.bulkUntrackAlerts(transformRequestBodyToApplicationV1(body));
|
||||
return res.noContent();
|
||||
} catch (e) {
|
||||
if (e instanceof RuleTypeDisabledError) {
|
||||
return e.sendResponse(res);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
})
|
||||
)
|
||||
);
|
||||
};
|
|
@ -0,0 +1,8 @@
|
|||
/*
|
||||
* 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 { bulkUntrackAlertRoute } from './bulk_untrack_alert_route';
|
|
@ -0,0 +1,8 @@
|
|||
/*
|
||||
* 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 { transformRequestBodyToApplication } from './transform_request_body_to_application/latest';
|
||||
export { transformRequestBodyToApplication as transformRequestBodyToApplicationV1 } from './transform_request_body_to_application/v1';
|
|
@ -0,0 +1,8 @@
|
|||
/*
|
||||
* 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 { transformRequestBodyToApplication } from './v1';
|
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* 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 { RewriteRequestCase } from '../../../../../lib';
|
||||
import { BulkUntrackBody } from '../../../../../../application/rule/methods/bulk_untrack/types';
|
||||
|
||||
export const transformRequestBodyToApplication: RewriteRequestCase<BulkUntrackBody> = ({
|
||||
indices,
|
||||
alert_uuids: alertUuids,
|
||||
}) => ({
|
||||
indices,
|
||||
alertUuids,
|
||||
});
|
|
@ -53,6 +53,7 @@ const createRulesClientMock = () => {
|
|||
clone: jest.fn(),
|
||||
getAlertFromRaw: jest.fn(),
|
||||
getScheduleFrequency: jest.fn(),
|
||||
bulkUntrackAlerts: jest.fn(),
|
||||
};
|
||||
return mocked;
|
||||
};
|
||||
|
|
|
@ -33,6 +33,7 @@ export enum RuleAuditAction {
|
|||
SNOOZE = 'rule_snooze',
|
||||
UNSNOOZE = 'rule_unsnooze',
|
||||
RUN_SOON = 'rule_run_soon',
|
||||
UNTRACK_ALERT = 'rule_alert_untrack',
|
||||
}
|
||||
|
||||
type VerbsTuple = [string, string, string];
|
||||
|
@ -81,6 +82,7 @@ const eventVerbs: Record<RuleAuditAction, VerbsTuple> = {
|
|||
'accessing global execution KPI for',
|
||||
'accessed global execution KPI for',
|
||||
],
|
||||
rule_alert_untrack: ['untrack', 'untracking', 'untracked'],
|
||||
};
|
||||
|
||||
const eventTypes: Record<RuleAuditAction, ArrayElement<EcsEvent['type']>> = {
|
||||
|
@ -107,6 +109,7 @@ const eventTypes: Record<RuleAuditAction, ArrayElement<EcsEvent['type']>> = {
|
|||
rule_run_soon: 'access',
|
||||
rule_get_execution_kpi: 'access',
|
||||
rule_get_global_execution_kpi: 'access',
|
||||
rule_alert_untrack: 'change',
|
||||
};
|
||||
|
||||
export interface RuleAuditEventParams {
|
||||
|
|
|
@ -8,17 +8,18 @@
|
|||
import { mapValues } from 'lodash';
|
||||
import { SAVED_OBJECT_REL_PRIMARY } from '@kbn/event-log-plugin/server';
|
||||
import { withSpan } from '@kbn/apm-utils';
|
||||
import { RawRule, SanitizedRule, RawAlertInstance as RawAlert } from '../../types';
|
||||
import { SanitizedRule, RawAlertInstance as RawAlert } from '../../types';
|
||||
import { taskInstanceToAlertTaskInstance } from '../../task_runner/alert_task_instance';
|
||||
import { Alert } from '../../alert';
|
||||
import { EVENT_LOG_ACTIONS } from '../../plugin';
|
||||
import { createAlertEventLogRecordObject } from '../../lib/create_alert_event_log_record_object';
|
||||
import { RulesClientContext } from '../types';
|
||||
import { RuleAttributes } from '../../data/rule/types';
|
||||
|
||||
export const untrackRuleAlerts = async (
|
||||
context: RulesClientContext,
|
||||
id: string,
|
||||
attributes: RawRule
|
||||
attributes: RuleAttributes
|
||||
) => {
|
||||
return withSpan({ name: 'untrackRuleAlerts', type: 'rules' }, async () => {
|
||||
if (!context.eventLogger || !attributes.scheduledTaskId) return;
|
||||
|
@ -27,7 +28,6 @@ export const untrackRuleAlerts = async (
|
|||
await context.taskManager.get(attributes.scheduledTaskId),
|
||||
attributes as unknown as SanitizedRule
|
||||
);
|
||||
|
||||
const { state } = taskInstance;
|
||||
|
||||
const untrackedAlerts = mapValues<Record<string, RawAlert>, Alert>(
|
||||
|
@ -78,24 +78,10 @@ export const untrackRuleAlerts = async (
|
|||
|
||||
// Untrack Lifecycle alerts (Alerts As Data-enabled)
|
||||
if (isLifecycleAlert) {
|
||||
const alertsClient = await context.alertsService?.createAlertsClient({
|
||||
namespace: context.namespace!,
|
||||
rule: {
|
||||
id,
|
||||
name: attributes.name,
|
||||
consumer: attributes.consumer,
|
||||
revision: attributes.revision,
|
||||
spaceId: context.spaceId,
|
||||
tags: attributes.tags,
|
||||
parameters: attributes.parameters,
|
||||
executionId: '',
|
||||
},
|
||||
ruleType,
|
||||
logger: context.logger,
|
||||
});
|
||||
if (!alertsClient) throw new Error('Could not create alertsClient');
|
||||
const indices = context.getAlertIndicesAlias([ruleType.id], context.spaceId);
|
||||
await alertsClient.setAlertStatusToUntracked(indices, [id]);
|
||||
if (!context.alertsService)
|
||||
throw new Error('Could not access alertsService to untrack alerts');
|
||||
await context.alertsService.setAlertsToUntracked({ indices, ruleIds: [id] });
|
||||
}
|
||||
} catch (error) {
|
||||
// this should not block the rest of the disable process
|
||||
|
|
|
@ -115,7 +115,7 @@ const bulkDisableRulesWithOCC = async (
|
|||
for await (const response of rulesFinder.find()) {
|
||||
await pMap(response.saved_objects, async (rule) => {
|
||||
try {
|
||||
await untrackRuleAlerts(context, rule.id, rule.attributes);
|
||||
await untrackRuleAlerts(context, rule.id, rule.attributes as RuleAttributes);
|
||||
|
||||
if (rule.attributes.name) {
|
||||
ruleNameToRuleIdMapping[rule.id] = rule.attributes.name;
|
||||
|
|
|
@ -12,7 +12,8 @@ import { retryIfConflicts } from '../../lib/retry_if_conflicts';
|
|||
import { bulkMarkApiKeysForInvalidation } from '../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation';
|
||||
import { ruleAuditEvent, RuleAuditAction } from '../common/audit_events';
|
||||
import { RulesClientContext } from '../types';
|
||||
import { migrateLegacyActions } from '../lib';
|
||||
import { untrackRuleAlerts, migrateLegacyActions } from '../lib';
|
||||
import { RuleAttributes } from '../../data/rule/types';
|
||||
|
||||
export async function deleteRule(context: RulesClientContext, { id }: { id: string }) {
|
||||
return await retryIfConflicts(
|
||||
|
@ -66,6 +67,8 @@ async function deleteWithOCC(context: RulesClientContext, { id }: { id: string }
|
|||
throw error;
|
||||
}
|
||||
|
||||
await untrackRuleAlerts(context, id, attributes as RuleAttributes);
|
||||
|
||||
// migrate legacy actions only for SIEM rules
|
||||
if (attributes.consumer === AlertConsumers.SIEM) {
|
||||
await migrateLegacyActions(context, { ruleId: id, attributes, skipActionsValidation: true });
|
||||
|
|
|
@ -12,6 +12,7 @@ import { retryIfConflicts } from '../../lib/retry_if_conflicts';
|
|||
import { ruleAuditEvent, RuleAuditAction } from '../common/audit_events';
|
||||
import { RulesClientContext } from '../types';
|
||||
import { untrackRuleAlerts, updateMeta, migrateLegacyActions } from '../lib';
|
||||
import { RuleAttributes } from '../../data/rule/types';
|
||||
|
||||
export async function disable(context: RulesClientContext, { id }: { id: string }): Promise<void> {
|
||||
return await retryIfConflicts(
|
||||
|
@ -43,8 +44,6 @@ async function disableWithOCC(context: RulesClientContext, { id }: { id: string
|
|||
references = alert.references;
|
||||
}
|
||||
|
||||
await untrackRuleAlerts(context, id, attributes);
|
||||
|
||||
try {
|
||||
await context.authorization.ensureAuthorized({
|
||||
ruleTypeId: attributes.alertTypeId,
|
||||
|
@ -63,6 +62,8 @@ async function disableWithOCC(context: RulesClientContext, { id }: { id: string
|
|||
throw error;
|
||||
}
|
||||
|
||||
await untrackRuleAlerts(context, id, attributes as RuleAttributes);
|
||||
|
||||
context.auditLogger?.log(
|
||||
ruleAuditEvent({
|
||||
action: RuleAuditAction.DISABLE,
|
||||
|
|
|
@ -62,6 +62,10 @@ import { listRuleTypes } from './methods/list_rule_types';
|
|||
import { getAlertFromRaw, GetAlertFromRawParams } from './lib/get_alert_from_raw';
|
||||
import { getTags, GetTagsParams } from './methods/get_tags';
|
||||
import { getScheduleFrequency } from '../application/rule/methods/get_schedule_frequency/get_schedule_frequency';
|
||||
import {
|
||||
bulkUntrackAlerts,
|
||||
BulkUntrackBody,
|
||||
} from '../application/rule/methods/bulk_untrack/bulk_untrack_alerts';
|
||||
|
||||
export type ConstructorOptions = Omit<
|
||||
RulesClientContext,
|
||||
|
@ -167,6 +171,8 @@ export class RulesClient {
|
|||
public muteInstance = (options: MuteAlertParams) => muteInstance(this.context, options);
|
||||
public unmuteInstance = (options: MuteAlertParams) => unmuteInstance(this.context, options);
|
||||
|
||||
public bulkUntrackAlerts = (options: BulkUntrackBody) => bulkUntrackAlerts(this.context, options);
|
||||
|
||||
public runSoon = (options: { id: string }) => runSoon(this.context, options);
|
||||
|
||||
public listRuleTypes = () => listRuleTypes(this.context);
|
||||
|
|
|
@ -58,7 +58,8 @@
|
|||
"@kbn/core-http-server-mocks",
|
||||
"@kbn/serverless",
|
||||
"@kbn/core-http-router-server-mocks",
|
||||
"@kbn/core-application-common",
|
||||
"@kbn/core-elasticsearch-server",
|
||||
"@kbn/core-application-common"
|
||||
],
|
||||
"exclude": ["target/**/*"]
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { act } from '@testing-library/react-hooks';
|
||||
import { kibanaStartMock } from '../../../utils/kibana_react.mock';
|
||||
import React from 'react';
|
||||
|
@ -44,6 +45,12 @@ jest.mock('../../../hooks/use_get_user_cases_permissions', () => ({
|
|||
useGetUserCasesPermissions: jest.fn(() => ({ create: true, read: true })),
|
||||
}));
|
||||
|
||||
jest.mock('@kbn/triggers-actions-ui-plugin/public/common/lib/kibana/kibana_react', () => ({
|
||||
useKibana: jest.fn(() => ({
|
||||
services: { notifications: { toasts: { addDanger: jest.fn(), addSuccess: jest.fn() } } },
|
||||
})),
|
||||
}));
|
||||
|
||||
const config = {
|
||||
unsafe: {
|
||||
alertDetails: {
|
||||
|
@ -69,6 +76,19 @@ describe('ObservabilityActions component', () => {
|
|||
});
|
||||
|
||||
const setup = async (pageId: string) => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
logger: {
|
||||
log: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
const props: Props = {
|
||||
config,
|
||||
data: inventoryThresholdAlert as unknown as TimelineNonEcsData[],
|
||||
|
@ -82,7 +102,11 @@ describe('ObservabilityActions component', () => {
|
|||
refresh,
|
||||
};
|
||||
|
||||
const wrapper = mountWithIntl(<AlertActions {...props} />);
|
||||
const wrapper = mountWithIntl(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AlertActions {...props} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
await act(async () => {
|
||||
await nextTick();
|
||||
wrapper.update();
|
||||
|
|
|
@ -20,8 +20,15 @@ import { CaseAttachmentsWithoutOwner } from '@kbn/cases-plugin/public';
|
|||
import { AttachmentType } from '@kbn/cases-plugin/common';
|
||||
import { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs';
|
||||
import { TimelineNonEcsData } from '@kbn/timelines-plugin/common';
|
||||
import { ALERT_RULE_TYPE_ID, OBSERVABILITY_THRESHOLD_RULE_TYPE_ID } from '@kbn/rule-data-utils';
|
||||
|
||||
import {
|
||||
ALERT_RULE_TYPE_ID,
|
||||
ALERT_RULE_UUID,
|
||||
ALERT_STATUS,
|
||||
ALERT_STATUS_ACTIVE,
|
||||
ALERT_UUID,
|
||||
OBSERVABILITY_THRESHOLD_RULE_TYPE_ID,
|
||||
} from '@kbn/rule-data-utils';
|
||||
import { useBulkUntrackAlerts } from '@kbn/triggers-actions-ui-plugin/public';
|
||||
import { useKibana } from '../../../utils/kibana_react';
|
||||
import { useGetUserCasesPermissions } from '../../../hooks/use_get_user_cases_permissions';
|
||||
import { isAlertDetailsEnabledPerApp } from '../../../utils/is_alert_details_enabled';
|
||||
|
@ -63,6 +70,7 @@ export function AlertActions({
|
|||
},
|
||||
} = useKibana().services;
|
||||
const userCasesPermissions = useGetUserCasesPermissions();
|
||||
const { mutateAsync: untrackAlerts } = useBulkUntrackAlerts();
|
||||
|
||||
const parseObservabilityAlert = useMemo(
|
||||
() => parseAlert(observabilityRuleTypeRegistry),
|
||||
|
@ -74,13 +82,13 @@ export function AlertActions({
|
|||
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
|
||||
|
||||
const ruleId = alert.fields['kibana.alert.rule.uuid'] ?? null;
|
||||
const ruleId = alert.fields[ALERT_RULE_UUID] ?? null;
|
||||
const linkToRule =
|
||||
pageId !== RULE_DETAILS_PAGE_ID && ruleId
|
||||
? prepend(paths.observability.ruleDetails(ruleId))
|
||||
: null;
|
||||
|
||||
const alertId = alert.fields['kibana.alert.uuid'] ?? null;
|
||||
const alertId = alert.fields[ALERT_UUID] ?? null;
|
||||
const linkToAlert =
|
||||
pageId !== ALERT_DETAILS_PAGE_ID && alertId
|
||||
? prepend(paths.observability.alertDetails(alertId))
|
||||
|
@ -99,6 +107,11 @@ export function AlertActions({
|
|||
: [];
|
||||
}, [ecsData, getRuleIdFromEvent, data]);
|
||||
|
||||
const isActiveAlert = useMemo(
|
||||
() => alert.fields[ALERT_STATUS] === ALERT_STATUS_ACTIVE,
|
||||
[alert.fields]
|
||||
);
|
||||
|
||||
const onSuccess = useCallback(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
@ -124,6 +137,14 @@ export function AlertActions({
|
|||
closeActionsPopover();
|
||||
};
|
||||
|
||||
const handleUntrackAlert = useCallback(async () => {
|
||||
await untrackAlerts({
|
||||
indices: [ecsData?._index ?? ''],
|
||||
alertUuids: [alertId],
|
||||
});
|
||||
onSuccess();
|
||||
}, [untrackAlerts, alertId, ecsData, onSuccess]);
|
||||
|
||||
const actionsMenuItems = [
|
||||
...(userCasesPermissions.create && userCasesPermissions.read
|
||||
? [
|
||||
|
@ -190,6 +211,19 @@ export function AlertActions({
|
|||
</EuiContextMenuItem>
|
||||
),
|
||||
],
|
||||
...(isActiveAlert
|
||||
? [
|
||||
<EuiContextMenuItem
|
||||
data-test-subj="untrackAlert"
|
||||
key="untrackAlert"
|
||||
onClick={handleUntrackAlert}
|
||||
>
|
||||
{i18n.translate('xpack.observability.alerts.actions.untrack', {
|
||||
defaultMessage: 'Mark as untracked',
|
||||
})}
|
||||
</EuiContextMenuItem>,
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
const actionsToolTip =
|
||||
|
|
|
@ -36,6 +36,7 @@ const createStartMock = () => {
|
|||
bulkDisable: jest.fn(),
|
||||
bulkEnable: jest.fn(),
|
||||
getRegisteredTypes: jest.fn(),
|
||||
bulkUpdateState: jest.fn(),
|
||||
};
|
||||
return mock;
|
||||
};
|
||||
|
|
|
@ -61,6 +61,7 @@ export type TaskManagerStartContract = Pick<
|
|||
| 'bulkEnable'
|
||||
| 'bulkDisable'
|
||||
| 'bulkSchedule'
|
||||
| 'bulkUpdateState'
|
||||
> &
|
||||
Pick<TaskStore, 'fetch' | 'aggregate' | 'get' | 'remove' | 'bulkRemove'> & {
|
||||
removeIfExists: TaskStore['remove'];
|
||||
|
@ -325,6 +326,7 @@ export class TaskManagerPlugin
|
|||
supportsEphemeralTasks: () =>
|
||||
this.config.ephemeral_tasks.enabled && this.shouldRunBackgroundTasks,
|
||||
getRegisteredTypes: () => this.definitions.getAllTypes(),
|
||||
bulkUpdateState: (...args) => taskScheduling.bulkUpdateState(...args),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -21,6 +21,7 @@ import { mockLogger } from './test_utils';
|
|||
import { TaskTypeDictionary } from './task_type_dictionary';
|
||||
import { ephemeralTaskLifecycleMock } from './ephemeral_task_lifecycle.mock';
|
||||
import { taskManagerMock } from './mocks';
|
||||
import { omit } from 'lodash';
|
||||
|
||||
let fakeTimer: sinon.SinonFakeTimers;
|
||||
jest.mock('uuid', () => ({
|
||||
|
@ -370,6 +371,135 @@ describe('TaskScheduling', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('bulkUpdateState', () => {
|
||||
const id = '01ddff11-e88a-4d13-bc4e-256164e755e2';
|
||||
beforeEach(() => {
|
||||
mockTaskStore.bulkUpdate.mockImplementation(() =>
|
||||
Promise.resolve([{ tag: 'ok', value: taskManagerMock.createTask() }])
|
||||
);
|
||||
});
|
||||
|
||||
test('should split search on chunks when input ids array too large', async () => {
|
||||
mockTaskStore.bulkGet.mockResolvedValue([]);
|
||||
const taskScheduling = new TaskScheduling(taskSchedulingOpts);
|
||||
|
||||
await taskScheduling.bulkUpdateState(Array.from({ length: 1250 }), jest.fn());
|
||||
|
||||
expect(mockTaskStore.bulkGet).toHaveBeenCalledTimes(13);
|
||||
});
|
||||
|
||||
test('should transform response into correct format', async () => {
|
||||
const successfulTask = taskManagerMock.createTask({
|
||||
id: 'task-1',
|
||||
enabled: false,
|
||||
schedule: { interval: '1h' },
|
||||
state: {
|
||||
'hello i am a state that has been modified': "not really but we're going to pretend",
|
||||
},
|
||||
});
|
||||
const failedToUpdateTask = taskManagerMock.createTask({
|
||||
id: 'task-2',
|
||||
enabled: true,
|
||||
schedule: { interval: '1h' },
|
||||
state: { 'this state is unchangeable': 'none shall update me' },
|
||||
});
|
||||
mockTaskStore.bulkUpdate.mockImplementation(() =>
|
||||
Promise.resolve([
|
||||
{ tag: 'ok', value: successfulTask },
|
||||
{
|
||||
tag: 'err',
|
||||
error: {
|
||||
type: 'task',
|
||||
id: failedToUpdateTask.id,
|
||||
error: {
|
||||
statusCode: 400,
|
||||
error: 'fail',
|
||||
message: 'fail',
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
);
|
||||
mockTaskStore.bulkGet.mockResolvedValue([asOk(successfulTask), asOk(failedToUpdateTask)]);
|
||||
|
||||
const taskScheduling = new TaskScheduling(taskSchedulingOpts);
|
||||
const result = await taskScheduling.bulkUpdateState(
|
||||
[successfulTask.id, failedToUpdateTask.id],
|
||||
jest.fn()
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
tasks: [successfulTask],
|
||||
errors: [
|
||||
{
|
||||
type: 'task',
|
||||
id: failedToUpdateTask.id,
|
||||
error: {
|
||||
statusCode: 400,
|
||||
error: 'fail',
|
||||
message: 'fail',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('should execute updater function on tasks', async () => {
|
||||
const task = taskManagerMock.createTask({
|
||||
id,
|
||||
enabled: false,
|
||||
schedule: { interval: '3h' },
|
||||
runAt: new Date('1969-09-13T21:33:58.285Z'),
|
||||
scheduledAt: new Date('1969-09-10T21:33:58.285Z'),
|
||||
state: { removeMe: 'please remove me i dont like being in this task manager state' },
|
||||
});
|
||||
const updaterFn = jest.fn((state) => {
|
||||
return {
|
||||
...omit(state, 'removeMe'),
|
||||
expectedValue: 'HELLO I AM AN EXPECTED VALUE IT IS VERY NICE TO MEET YOU',
|
||||
};
|
||||
});
|
||||
mockTaskStore.bulkUpdate.mockImplementation(() =>
|
||||
Promise.resolve([{ tag: 'ok', value: task }])
|
||||
);
|
||||
mockTaskStore.bulkGet.mockResolvedValue([asOk(task)]);
|
||||
|
||||
const taskScheduling = new TaskScheduling(taskSchedulingOpts);
|
||||
await taskScheduling.bulkUpdateState([id], updaterFn);
|
||||
|
||||
const bulkUpdatePayload = mockTaskStore.bulkUpdate.mock.calls[0][0];
|
||||
|
||||
expect(bulkUpdatePayload).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"attempts": 0,
|
||||
"enabled": false,
|
||||
"id": "01ddff11-e88a-4d13-bc4e-256164e755e2",
|
||||
"ownerId": "123",
|
||||
"params": Object {
|
||||
"hello": "world",
|
||||
},
|
||||
"retryAt": null,
|
||||
"runAt": 1969-09-13T21:33:58.285Z,
|
||||
"schedule": Object {
|
||||
"interval": "3h",
|
||||
},
|
||||
"scheduledAt": 1969-09-10T21:33:58.285Z,
|
||||
"scope": undefined,
|
||||
"startedAt": null,
|
||||
"state": Object {
|
||||
"expectedValue": "HELLO I AM AN EXPECTED VALUE IT IS VERY NICE TO MEET YOU",
|
||||
},
|
||||
"status": "idle",
|
||||
"taskType": "foo",
|
||||
"user": undefined,
|
||||
"version": "123",
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bulkUpdateSchedules', () => {
|
||||
const id = '01ddff11-e88a-4d13-bc4e-256164e755e2';
|
||||
beforeEach(() => {
|
||||
|
|
|
@ -186,6 +186,23 @@ export class TaskScheduling {
|
|||
});
|
||||
}
|
||||
|
||||
public async bulkUpdateState(
|
||||
taskIds: string[],
|
||||
stateMapFn: (s: ConcreteTaskInstance['state'], id: string) => ConcreteTaskInstance['state']
|
||||
) {
|
||||
return await retryableBulkUpdate({
|
||||
taskIds,
|
||||
store: this.store,
|
||||
getTasks: async (ids) => await this.bulkGetTasksHelper(ids),
|
||||
filter: () => true,
|
||||
map: (task) => ({
|
||||
...task,
|
||||
state: stateMapFn(task.state, task.id),
|
||||
}),
|
||||
validate: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk updates schedules for tasks by ids.
|
||||
* Only tasks with `idle` status will be updated, as for the tasks which have `running` status,
|
||||
|
|
|
@ -53,6 +53,12 @@ jest.mock('@kbn/kibana-react-plugin/public', () => {
|
|||
useKibana: () => ({
|
||||
services: {
|
||||
cases: mockCaseService,
|
||||
notifications: {
|
||||
toasts: {
|
||||
addDanger: jest.fn(),
|
||||
addSuccess: jest.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
|
|
@ -49,6 +49,12 @@ const mockCaseService = createCasesServiceMock();
|
|||
const mockKibana = jest.fn().mockReturnValue({
|
||||
services: {
|
||||
cases: mockCaseService,
|
||||
notifications: {
|
||||
toasts: {
|
||||
addDanger: jest.fn(),
|
||||
addSuccess: jest.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -48,3 +48,10 @@ export const ALERTS_ALREADY_ATTACHED_TO_CASE = i18n.translate(
|
|||
defaultMessage: 'All selected alerts are already attached to the case',
|
||||
}
|
||||
);
|
||||
|
||||
export const MARK_AS_UNTRACKED = i18n.translate(
|
||||
'xpack.triggersActionsUI.alerts.table.actions.markAsUntracked',
|
||||
{
|
||||
defaultMessage: 'Mark as untracked',
|
||||
}
|
||||
);
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
*/
|
||||
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import { useBulkActions, useBulkAddToCaseActions } from './use_bulk_actions';
|
||||
import { useBulkActions, useBulkAddToCaseActions, useBulkUntrackActions } from './use_bulk_actions';
|
||||
import { AppMockRenderer, createAppMockRenderer } from '../../test_utils';
|
||||
import { createCasesServiceMock } from '../index.mock';
|
||||
|
||||
|
@ -43,6 +43,7 @@ describe('bulk action hooks', () => {
|
|||
const refresh = jest.fn();
|
||||
const clearSelection = jest.fn();
|
||||
const openNewCase = jest.fn();
|
||||
const setIsBulkActionsLoading = jest.fn();
|
||||
|
||||
const openExistingCase = jest.fn().mockImplementation(({ getAttachments }) => {
|
||||
getAttachments({ theCase: { id: caseId } });
|
||||
|
@ -295,14 +296,40 @@ describe('bulk action hooks', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('useBulkUntrackActions', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
it('should not how the bulk actions when the user lacks any observability permissions', () => {
|
||||
mockKibana.mockImplementation(() => ({
|
||||
services: {
|
||||
application: { capabilities: {} },
|
||||
},
|
||||
}));
|
||||
const { result } = renderHook(
|
||||
() => useBulkUntrackActions({ setIsBulkActionsLoading, refresh, clearSelection }),
|
||||
{
|
||||
wrapper: appMockRender.AppWrapper,
|
||||
}
|
||||
);
|
||||
|
||||
expect(result.current.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useBulkActions', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockKibana.mockImplementation(() => ({ services: { cases: mockCaseService } }));
|
||||
mockKibana.mockImplementation(() => ({
|
||||
services: {
|
||||
cases: mockCaseService,
|
||||
application: { capabilities: { infrastructure: { show: true } } },
|
||||
},
|
||||
}));
|
||||
mockCaseService.helpers.canUseCases = jest.fn().mockReturnValue({ create: true, read: true });
|
||||
});
|
||||
|
||||
it('appends the case bulk actions', async () => {
|
||||
it('appends the case and untrack bulk actions', async () => {
|
||||
const { result } = renderHook(
|
||||
() => useBulkActions({ alerts: [], query: {}, casesConfig, refresh }),
|
||||
{
|
||||
|
@ -331,6 +358,14 @@ describe('bulk action hooks', () => {
|
|||
"label": "Add to existing case",
|
||||
"onClick": [Function],
|
||||
},
|
||||
Object {
|
||||
"data-test-subj": "mark-as-untracked",
|
||||
"disableOnQuery": true,
|
||||
"disabledLabel": "Mark as untracked",
|
||||
"key": "mark-as-untracked",
|
||||
"label": "Mark as untracked",
|
||||
"onClick": [Function],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
|
|
@ -27,9 +27,11 @@ import {
|
|||
ADD_TO_EXISTING_CASE,
|
||||
ADD_TO_NEW_CASE,
|
||||
ALERTS_ALREADY_ATTACHED_TO_CASE,
|
||||
MARK_AS_UNTRACKED,
|
||||
NO_ALERTS_ADDED_TO_CASE,
|
||||
} from './translations';
|
||||
import { TimelineItem } from '../bulk_actions/components/toolbar';
|
||||
import { useBulkUntrackAlerts } from './use_bulk_untrack_alerts';
|
||||
|
||||
interface BulkActionsProps {
|
||||
query: Pick<QueryDslQueryContainer, 'bool' | 'ids'>;
|
||||
|
@ -51,6 +53,9 @@ export interface UseBulkActions {
|
|||
type UseBulkAddToCaseActionsProps = Pick<BulkActionsProps, 'casesConfig' | 'refresh'> &
|
||||
Pick<UseBulkActions, 'clearSelection'>;
|
||||
|
||||
type UseBulkUntrackActionsProps = Pick<BulkActionsProps, 'refresh'> &
|
||||
Pick<UseBulkActions, 'clearSelection' | 'setIsBulkActionsLoading'>;
|
||||
|
||||
const filterAlertsAlreadyAttachedToCase = (alerts: TimelineItem[], caseId: string) =>
|
||||
alerts.filter(
|
||||
(alert) =>
|
||||
|
@ -171,6 +176,60 @@ export const useBulkAddToCaseActions = ({
|
|||
]);
|
||||
};
|
||||
|
||||
export const useBulkUntrackActions = ({
|
||||
setIsBulkActionsLoading,
|
||||
refresh,
|
||||
clearSelection,
|
||||
}: UseBulkUntrackActionsProps) => {
|
||||
const onSuccess = useCallback(() => {
|
||||
refresh();
|
||||
clearSelection();
|
||||
}, [clearSelection, refresh]);
|
||||
|
||||
const { application } = useKibana().services;
|
||||
const { mutateAsync: untrackAlerts } = useBulkUntrackAlerts();
|
||||
// Check if at least one Observability feature is enabled
|
||||
if (!application?.capabilities) return [];
|
||||
const hasApmPermission = application.capabilities.apm?.['alerting:show'];
|
||||
const hasInfrastructurePermission = application.capabilities.infrastructure?.show;
|
||||
const hasLogsPermission = application.capabilities.logs?.show;
|
||||
const hasUptimePermission = application.capabilities.uptime?.show;
|
||||
const hasSloPermission = application.capabilities.slo?.show;
|
||||
const hasObservabilityPermission = application.capabilities.observability?.show;
|
||||
|
||||
if (
|
||||
!hasApmPermission &&
|
||||
!hasInfrastructurePermission &&
|
||||
!hasLogsPermission &&
|
||||
!hasUptimePermission &&
|
||||
!hasSloPermission &&
|
||||
!hasObservabilityPermission
|
||||
)
|
||||
return [];
|
||||
|
||||
return [
|
||||
{
|
||||
label: MARK_AS_UNTRACKED,
|
||||
key: 'mark-as-untracked',
|
||||
disableOnQuery: true,
|
||||
disabledLabel: MARK_AS_UNTRACKED,
|
||||
'data-test-subj': 'mark-as-untracked',
|
||||
onClick: async (alerts?: TimelineItem[]) => {
|
||||
if (!alerts) return;
|
||||
const alertUuids = alerts.map((alert) => alert._id);
|
||||
const indices = alerts.map((alert) => alert._index ?? '');
|
||||
try {
|
||||
setIsBulkActionsLoading(true);
|
||||
await untrackAlerts({ indices, alertUuids });
|
||||
onSuccess();
|
||||
} finally {
|
||||
setIsBulkActionsLoading(false);
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export function useBulkActions({
|
||||
alerts,
|
||||
casesConfig,
|
||||
|
@ -184,15 +243,24 @@ export function useBulkActions({
|
|||
const clearSelection = () => {
|
||||
updateBulkActionsState({ action: BulkActionsVerbs.clear });
|
||||
};
|
||||
const setIsBulkActionsLoading = (isLoading: boolean = true) => {
|
||||
updateBulkActionsState({ action: BulkActionsVerbs.updateAllLoadingState, isLoading });
|
||||
};
|
||||
const caseBulkActions = useBulkAddToCaseActions({ casesConfig, refresh, clearSelection });
|
||||
const untrackBulkActions = useBulkUntrackActions({
|
||||
setIsBulkActionsLoading,
|
||||
refresh,
|
||||
clearSelection,
|
||||
});
|
||||
|
||||
const bulkActions =
|
||||
caseBulkActions.length !== 0
|
||||
? addItemsToInitialPanel({
|
||||
panels: configBulkActionPanels,
|
||||
items: caseBulkActions,
|
||||
})
|
||||
: configBulkActionPanels;
|
||||
const initialItems = [...caseBulkActions, ...untrackBulkActions];
|
||||
|
||||
const bulkActions = initialItems.length
|
||||
? addItemsToInitialPanel({
|
||||
panels: configBulkActionPanels,
|
||||
items: initialItems,
|
||||
})
|
||||
: configBulkActionPanels;
|
||||
|
||||
const isBulkActionsColumnActive = bulkActions.length !== 0;
|
||||
|
||||
|
@ -200,10 +268,6 @@ export function useBulkActions({
|
|||
updateBulkActionsState({ action: BulkActionsVerbs.rowCountUpdate, rowCount: alerts.length });
|
||||
}, [alerts, updateBulkActionsState]);
|
||||
|
||||
const setIsBulkActionsLoading = (isLoading: boolean = true) => {
|
||||
updateBulkActionsState({ action: BulkActionsVerbs.updateAllLoadingState, isLoading });
|
||||
};
|
||||
|
||||
return {
|
||||
isBulkActionsColumnActive,
|
||||
getBulkActionsLeadingControlColumn,
|
||||
|
|
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
* 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 { i18n } from '@kbn/i18n';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { INTERNAL_BASE_ALERTING_API_PATH } from '@kbn/alerting-plugin/common';
|
||||
import { useKibana } from '../../../../common';
|
||||
|
||||
export const useBulkUntrackAlerts = () => {
|
||||
const {
|
||||
http,
|
||||
notifications: { toasts },
|
||||
} = useKibana().services;
|
||||
|
||||
const untrackAlerts = useMutation<string, string, { indices: string[]; alertUuids: string[] }>(
|
||||
['untrackAlerts'],
|
||||
({ indices, alertUuids }) => {
|
||||
try {
|
||||
const body = JSON.stringify({
|
||||
...(indices?.length ? { indices } : {}),
|
||||
...(alertUuids ? { alert_uuids: alertUuids } : {}),
|
||||
});
|
||||
return http.post(`${INTERNAL_BASE_ALERTING_API_PATH}/alerts/_bulk_untrack`, { body });
|
||||
} catch (e) {
|
||||
throw new Error(`Unable to parse bulk untrack params: ${e}`);
|
||||
}
|
||||
},
|
||||
{
|
||||
onError: (_err, params) => {
|
||||
toasts.addDanger(
|
||||
i18n.translate(
|
||||
'xpack.triggersActionsUI.rules.deleteConfirmationModal.errorNotification.descriptionText',
|
||||
{
|
||||
defaultMessage: 'Failed to untrack {uuidsCount, plural, one {alert} other {alerts}}',
|
||||
values: { uuidsCount: params.alertUuids.length },
|
||||
}
|
||||
)
|
||||
);
|
||||
},
|
||||
|
||||
onSuccess: (_, params) => {
|
||||
toasts.addSuccess(
|
||||
i18n.translate(
|
||||
'xpack.triggersActionsUI.rules.deleteConfirmationModal.successNotification.descriptionText',
|
||||
{
|
||||
defaultMessage: 'Untracked {uuidsCount, plural, one {alert} other {alerts}}',
|
||||
values: { uuidsCount: params.alertUuids.length },
|
||||
}
|
||||
)
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return untrackAlerts;
|
||||
};
|
|
@ -152,3 +152,5 @@ export const getNotifyWhenOptions = async () => {
|
|||
export { transformRule } from './application/lib/rule_api/common_transformations';
|
||||
|
||||
export { validateActionFilterQuery } from './application/lib/value_validators';
|
||||
|
||||
export { useBulkUntrackAlerts } from './application/sections/alerts_table/hooks/use_bulk_untrack_alerts';
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
import type { Client } from '@elastic/elasticsearch';
|
||||
import { DeleteByQueryRequest } from '@elastic/elasticsearch/lib/api/types';
|
||||
|
||||
export const ES_TEST_INDEX_NAME = '.kibana-alerting-test-data';
|
||||
|
||||
|
@ -135,13 +136,12 @@ export class ESTestIndexTool {
|
|||
}
|
||||
|
||||
async removeAll() {
|
||||
const params = {
|
||||
const params: DeleteByQueryRequest = {
|
||||
index: this.index,
|
||||
body: {
|
||||
query: {
|
||||
match_all: {},
|
||||
},
|
||||
query: {
|
||||
match_all: {},
|
||||
},
|
||||
conflicts: 'proceed',
|
||||
};
|
||||
return await this.es.deleteByQuery(params);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,132 @@
|
|||
/*
|
||||
* 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 { ES_TEST_INDEX_NAME } from '@kbn/alerting-api-integration-helpers';
|
||||
import { ALERT_STATUS, ALERT_UUID } from '@kbn/rule-data-utils';
|
||||
import { getUrlPrefix, ObjectRemover, getTestRuleData, getEventLog } from '../../../../common/lib';
|
||||
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
|
||||
import { UserAtSpaceScenarios } from '../../../scenarios';
|
||||
|
||||
const alertAsDataIndex = '.internal.alerts-observability.test.alerts.alerts-default-000001';
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default function bulkUntrackTests({ getService }: FtrProviderContext) {
|
||||
const supertest = getService('supertest');
|
||||
const supertestWithoutAuth = getService('supertestWithoutAuth');
|
||||
const retry = getService('retry');
|
||||
const es = getService('es');
|
||||
|
||||
describe('bulk untrack', () => {
|
||||
const objectRemover = new ObjectRemover(supertest);
|
||||
|
||||
afterEach(async () => {
|
||||
await es.deleteByQuery({
|
||||
index: alertAsDataIndex,
|
||||
query: {
|
||||
match_all: {},
|
||||
},
|
||||
conflicts: 'proceed',
|
||||
});
|
||||
objectRemover.removeAll();
|
||||
});
|
||||
|
||||
for (const scenario of UserAtSpaceScenarios) {
|
||||
describe(scenario.id, () => {
|
||||
it('should bulk mark alerts as untracked', async () => {
|
||||
const { body: createdRule } = await supertest
|
||||
.post(`${getUrlPrefix(scenario.space.id)}/api/alerting/rule`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send(
|
||||
getTestRuleData({
|
||||
rule_type_id: 'test.always-firing-alert-as-data',
|
||||
schedule: { interval: '24h' },
|
||||
throttle: undefined,
|
||||
notify_when: undefined,
|
||||
params: {
|
||||
index: ES_TEST_INDEX_NAME,
|
||||
reference: 'test',
|
||||
},
|
||||
})
|
||||
)
|
||||
.expect(200);
|
||||
objectRemover.add(scenario.space.id, createdRule.id, 'rule', 'alerting');
|
||||
|
||||
await retry.try(async () => {
|
||||
return await getEventLog({
|
||||
getService,
|
||||
spaceId: scenario.space.id,
|
||||
type: 'alert',
|
||||
id: createdRule.id,
|
||||
provider: 'alerting',
|
||||
actions: new Map([['active-instance', { equal: 2 }]]),
|
||||
});
|
||||
});
|
||||
|
||||
await retry.try(async () => {
|
||||
const {
|
||||
hits: { hits: activeAlerts },
|
||||
} = await es.search({
|
||||
index: alertAsDataIndex,
|
||||
body: { query: { match_all: {} } },
|
||||
});
|
||||
|
||||
activeAlerts.forEach((activeAlert: any) => {
|
||||
expect(activeAlert._source[ALERT_STATUS]).eql('active');
|
||||
});
|
||||
});
|
||||
|
||||
const {
|
||||
hits: { hits: activeAlerts },
|
||||
} = await es.search({
|
||||
index: alertAsDataIndex,
|
||||
body: { query: { match_all: {} } },
|
||||
});
|
||||
|
||||
const ids = activeAlerts.map((activeAlert: any) => activeAlert._source[ALERT_UUID]);
|
||||
|
||||
const response = await supertestWithoutAuth
|
||||
.post(`${getUrlPrefix(scenario.space.id)}/internal/alerting/alerts/_bulk_untrack`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.auth(scenario.user.username, scenario.user.password)
|
||||
.send({
|
||||
indices: [alertAsDataIndex],
|
||||
alert_uuids: ids,
|
||||
});
|
||||
|
||||
switch (scenario.id) {
|
||||
case 'no_kibana_privileges at space1':
|
||||
case 'space_1_all at space1':
|
||||
case 'space_1_all at space2':
|
||||
case 'global_read at space1':
|
||||
case 'space_1_all_alerts_none_actions at space1':
|
||||
case 'space_1_all_with_restricted_fixture at space1':
|
||||
expect(response.statusCode).to.eql(403);
|
||||
break;
|
||||
case 'superuser at space1':
|
||||
expect(response.statusCode).to.eql(204);
|
||||
await retry.try(async () => {
|
||||
const {
|
||||
hits: { hits: untrackedAlerts },
|
||||
} = await es.search({
|
||||
index: alertAsDataIndex,
|
||||
body: { query: { match_all: {} } },
|
||||
});
|
||||
|
||||
untrackedAlerts.forEach((untrackedAlert: any) => {
|
||||
expect(untrackedAlert._source[ALERT_STATUS]).eql('untracked');
|
||||
});
|
||||
});
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
|
@ -32,6 +32,7 @@ export default function alertingTests({ loadTestFile, getService }: FtrProviderC
|
|||
loadTestFile(require.resolve('./get_alert_summary'));
|
||||
loadTestFile(require.resolve('./rule_types'));
|
||||
loadTestFile(require.resolve('./retain_api_key'));
|
||||
loadTestFile(require.resolve('./bulk_untrack'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -160,7 +160,11 @@ export async function deleteApmRules(supertest: SuperTest<Test>) {
|
|||
}
|
||||
|
||||
export function deleteApmAlerts(es: Client) {
|
||||
return es.deleteByQuery({ index: APM_ALERTS_INDEX, query: { match_all: {} } });
|
||||
return es.deleteByQuery({
|
||||
index: APM_ALERTS_INDEX,
|
||||
conflicts: 'proceed',
|
||||
query: { match_all: {} },
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearKibanaApmEventLog(es: Client) {
|
||||
|
|
|
@ -54,7 +54,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
|
|||
await waitAndClickByTestId('close');
|
||||
|
||||
const headers = await find.allByCssSelector('.euiDataGridHeaderCell');
|
||||
expect(headers.length).to.be(6);
|
||||
expect(headers.length).to.be(7);
|
||||
});
|
||||
|
||||
it('should take into account the column type when sorting', async () => {
|
||||
|
|
|
@ -59,10 +59,12 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
await esClient.deleteByQuery({
|
||||
index: CUSTOM_THRESHOLD_RULE_ALERT_INDEX,
|
||||
query: { term: { 'kibana.alert.rule.uuid': ruleId } },
|
||||
conflicts: 'proceed',
|
||||
});
|
||||
await esClient.deleteByQuery({
|
||||
index: '.kibana-event-log-*',
|
||||
query: { term: { 'rule.id': ruleId } },
|
||||
conflicts: 'proceed',
|
||||
});
|
||||
await dataViewApi.delete({
|
||||
id: DATA_VIEW_ID,
|
||||
|
|
|
@ -47,10 +47,12 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
await esClient.deleteByQuery({
|
||||
index: CUSTOM_THRESHOLD_RULE_ALERT_INDEX,
|
||||
query: { term: { 'kibana.alert.rule.uuid': ruleId } },
|
||||
conflicts: 'proceed',
|
||||
});
|
||||
await esClient.deleteByQuery({
|
||||
index: '.kibana-event-log-*',
|
||||
query: { term: { 'rule.id': ruleId } },
|
||||
conflicts: 'proceed',
|
||||
});
|
||||
await dataViewApi.delete({
|
||||
id: DATA_VIEW_ID,
|
||||
|
|
|
@ -61,10 +61,12 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
await esClient.deleteByQuery({
|
||||
index: CUSTOM_THRESHOLD_RULE_ALERT_INDEX,
|
||||
query: { term: { 'kibana.alert.rule.uuid': ruleId } },
|
||||
conflicts: 'proceed',
|
||||
});
|
||||
await esClient.deleteByQuery({
|
||||
index: '.kibana-event-log-*',
|
||||
query: { term: { 'rule.id': ruleId } },
|
||||
conflicts: 'proceed',
|
||||
});
|
||||
await dataViewApi.delete({
|
||||
id: DATA_VIEW_ID,
|
||||
|
|
|
@ -55,10 +55,12 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
await esClient.deleteByQuery({
|
||||
index: CUSTOM_THRESHOLD_RULE_ALERT_INDEX,
|
||||
query: { term: { 'kibana.alert.rule.uuid': ruleId } },
|
||||
conflicts: 'proceed',
|
||||
});
|
||||
await esClient.deleteByQuery({
|
||||
index: '.kibana-event-log-*',
|
||||
query: { term: { 'rule.id': ruleId } },
|
||||
conflicts: 'proceed',
|
||||
});
|
||||
await dataViewApi.delete({
|
||||
id: DATA_VIEW_ID,
|
||||
|
|
|
@ -65,10 +65,12 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
await esClient.deleteByQuery({
|
||||
index: CUSTOM_THRESHOLD_RULE_ALERT_INDEX,
|
||||
query: { term: { 'kibana.alert.rule.uuid': ruleId } },
|
||||
conflicts: 'proceed',
|
||||
});
|
||||
await esClient.deleteByQuery({
|
||||
index: '.kibana-event-log-*',
|
||||
query: { term: { 'rule.id': ruleId } },
|
||||
conflicts: 'proceed',
|
||||
});
|
||||
await dataViewApi.delete({
|
||||
id: DATA_VIEW_ID,
|
||||
|
|
|
@ -17,7 +17,8 @@ export default ({ getPageObject, getService }: FtrProviderContext) => {
|
|||
const cases = getService('cases');
|
||||
const toasts = getService('toasts');
|
||||
|
||||
describe('Configure Case', function () {
|
||||
// FLAKY: https://github.com/elastic/kibana/issues/166469
|
||||
describe.skip('Configure Case', function () {
|
||||
before(async () => {
|
||||
await svlCommonPage.login();
|
||||
|
||||
|
@ -31,8 +32,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => {
|
|||
await svlCommonPage.forceLogout();
|
||||
});
|
||||
|
||||
// FLAKY: https://github.com/elastic/kibana/issues/166469
|
||||
describe.skip('Closure options', function () {
|
||||
describe('Closure options', function () {
|
||||
before(async () => {
|
||||
await common.clickAndValidate('configure-case-button', 'case-configure-title');
|
||||
});
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue