[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:
Zacqary Adam Xeper 2023-10-03 19:47:07 -05:00 committed by GitHub
parent e74e5e2bfc
commit 5b1ae6683f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
57 changed files with 1647 additions and 163 deletions

View file

@ -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';

View file

@ -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';

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { schema } from '@kbn/config-schema';
export const bulkUntrackBodySchema = schema.object({
indices: schema.arrayOf(schema.string()),
alert_uuids: schema.arrayOf(schema.string()),
});

View file

@ -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';

View file

@ -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>;

View file

@ -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'
);
});
});
});
}
});

View file

@ -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}'
}`;

View file

@ -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,

View file

@ -12,6 +12,7 @@ const creatAlertsServiceMock = () => {
isInitialized: jest.fn(),
getContextInitializationPromise: jest.fn(),
createAlertsClient: jest.fn(),
setAlertsToUntracked: jest.fn(),
};
});
};

View file

@ -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,
});
}
}

View file

@ -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;
});
});

View file

@ -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}'
}`;

View file

@ -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' },
() =>

View file

@ -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",
},
],
}
`);
});
});

View file

@ -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;
}
}

View file

@ -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()),
});

View file

@ -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';

View file

@ -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>;

View file

@ -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';

View file

@ -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);
}

View file

@ -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;
}
})
)
);
};

View file

@ -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';

View file

@ -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';

View file

@ -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';

View file

@ -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,
});

View file

@ -53,6 +53,7 @@ const createRulesClientMock = () => {
clone: jest.fn(),
getAlertFromRaw: jest.fn(),
getScheduleFrequency: jest.fn(),
bulkUntrackAlerts: jest.fn(),
};
return mocked;
};

View file

@ -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 {

View file

@ -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

View file

@ -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;

View file

@ -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 });

View file

@ -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,

View file

@ -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);

View file

@ -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/**/*"]
}

View file

@ -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();

View file

@ -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 =

View file

@ -36,6 +36,7 @@ const createStartMock = () => {
bulkDisable: jest.fn(),
bulkEnable: jest.fn(),
getRegisteredTypes: jest.fn(),
bulkUpdateState: jest.fn(),
};
return mock;
};

View file

@ -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),
};
}
}

View file

@ -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(() => {

View file

@ -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,

View file

@ -53,6 +53,12 @@ jest.mock('@kbn/kibana-react-plugin/public', () => {
useKibana: () => ({
services: {
cases: mockCaseService,
notifications: {
toasts: {
addDanger: jest.fn(),
addSuccess: jest.fn(),
},
},
},
}),
};

View file

@ -49,6 +49,12 @@ const mockCaseService = createCasesServiceMock();
const mockKibana = jest.fn().mockReturnValue({
services: {
cases: mockCaseService,
notifications: {
toasts: {
addDanger: jest.fn(),
addSuccess: jest.fn(),
},
},
},
});

View file

@ -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',
}
);

View file

@ -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],
},
],
},
]

View file

@ -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,

View file

@ -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;
};

View file

@ -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';

View file

@ -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);
}

View file

@ -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)}`);
}
});
});
}
});
}

View file

@ -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'));
});
});
}

View file

@ -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) {

View file

@ -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 () => {

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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');
});