[Cases] Case action (#168369)

## Summary

Depends on: https://github.com/elastic/kibana/pull/166267,
https://github.com/elastic/kibana/pull/170326,
https://github.com/elastic/kibana/pull/169484,
https://github.com/elastic/kibana/pull/173740,
https://github.com/elastic/kibana/pull/173763,
https://github.com/elastic/kibana/pull/178068,
https://github.com/elastic/kibana/pull/178307,
https://github.com/elastic/kibana/pull/178600,
https://github.com/elastic/kibana/pull/180437

PRs:
- https://github.com/elastic/kibana/pull/168370
- https://github.com/elastic/kibana/pull/169229
- https://github.com/elastic/kibana/pull/171754
- https://github.com/elastic/kibana/pull/172709
- https://github.com/elastic/kibana/pull/173012
- https://github.com/elastic/kibana/pull/175107
- https://github.com/elastic/kibana/pull/175452
- https://github.com/elastic/kibana/pull/175505
- https://github.com/elastic/kibana/pull/177033
- https://github.com/elastic/kibana/pull/178277
- https://github.com/elastic/kibana/pull/177139
- https://github.com/elastic/kibana/pull/179796

Fixes: https://github.com/elastic/kibana/issues/153837

## Testing

Run Kibana with `--run-examples` if you want to use the "Always firing"
rule.

Create a rule with a case action in observability and the stack. The
security solution is not supported. You should not be able to assign a
case action in a security solution rule.

1. Test the "Reopen closed cases" configuration.
2. Test the "Grouping by" configuration. Only one field is allowed. Not
all fields are persisted in alerts. If you select a field not part of
the alert the case action will create a case where the grouping value is
set to `unknow`.
3. Test the "Time window" feature. You can comment out the validation to
test for shorter times.
4. Verify that the case action is experimental.
5. Verify that based on the rule type the case is created in the correct
solution.
6. Verify that you cannot create a rule with the case action on the
basic license.
7. Verify that the execution of the case action fails if you do not have
permission for cases. Pending work on the system actions framework level
to not allow users to create rules with system actions where they do not
have permission.
8. Stress test the case action by creating multiple rules.

### Checklist

Delete any items that are not applicable to this PR.

- [x]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [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

### For maintainers

- [x] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

## Release notes

Automatically create cases when an alert is triggered.

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: adcoelho <antonio.coelho@elastic.co>
Co-authored-by: Janki Salvi <117571355+js-jankisalvi@users.noreply.github.com>
This commit is contained in:
Christos Nasikas 2024-04-12 12:01:17 +03:00 committed by GitHub
parent c837518650
commit b735d8c569
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
106 changed files with 11526 additions and 229 deletions

View file

@ -146,6 +146,7 @@ export const HASH_TO_VERSION_MAP = {
'cases-comments|93535d41ca0279a4a2e5d08acd3f28e3': '10.0.0',
'cases-configure|c124bd0be4c139d0f0f91fb9eeca8e37': '10.0.0',
'cases-connector-mappings|a98c33813f364f0b068e8c592ac6ef6d': '10.0.0',
'cases-rules|1cb4b03690489e07aa86f283dcea5ce1': '10.0.0',
'cases-telemetry|3d1b76c39bfb2cc8296b024d73854724': '10.0.0',
'cases-user-actions|07a6651cf37853dd5d64bfb2c796e102': '10.0.0',
'cases|8f7dc53b17c272ea19f831537daa082d': '10.1.0',

View file

@ -195,6 +195,13 @@
"cases-connector-mappings": [
"owner"
],
"cases-rules": [
"counter",
"createdAt",
"rules",
"rules.id",
"updatedAt"
],
"cases-telemetry": [],
"cases-user-actions": [
"action",

View file

@ -669,6 +669,27 @@
}
}
},
"cases-rules": {
"dynamic": false,
"properties": {
"counter": {
"type": "unsigned_long"
},
"createdAt": {
"type": "date"
},
"rules": {
"properties": {
"id": {
"type": "keyword"
}
}
},
"updatedAt": {
"type": "date"
}
}
},
"cases-telemetry": {
"dynamic": false,
"properties": {}
@ -1568,11 +1589,11 @@
"assetType": {
"type": "keyword"
},
"dashboardSavedObjectId": {
"type": "keyword"
},
"dashboardFilterAssetIdEnabled": {
"type": "boolean"
},
"dashboardSavedObjectId": {
"type": "keyword"
}
}
},

View file

@ -74,6 +74,7 @@ describe('checking migration metadata changes on all registered SO types', () =>
"cases-comments": "5cb0a421588831c2a950e50f486048b8aabbae25",
"cases-configure": "44ed7b8e0f44df39516b8870589b89e32224d2bf",
"cases-connector-mappings": "f9d1ac57e484e69506c36a8051e4d61f4a8cfd25",
"cases-rules": "6d1776f5c46a99e1a0f3085c537146c1cdfbc829",
"cases-telemetry": "f219eb7e26772884342487fc9602cfea07b3cedc",
"cases-user-actions": "483f10db9b3bd1617948d7032a98b7791bf87414",
"cloud-security-posture-settings": "e0f61c68bbb5e4cfa46ce8994fa001e417df51ca",

View file

@ -34,6 +34,7 @@ const previouslyRegisteredTypes = [
'cases-comments',
'cases-configure',
'cases-connector-mappings',
'cases-rules',
'cases-sub-case',
'cases-user-actions',
'cases-telemetry',

View file

@ -195,6 +195,7 @@ describe('split .kibana index into multiple system indices', () => {
"cases-comments",
"cases-configure",
"cases-connector-mappings",
"cases-rules",
"cases-telemetry",
"cases-user-actions",
"cloud-security-posture-settings",

View file

@ -36,7 +36,7 @@ describe('createSystemConnectors', () => {
{
id: 'system-connector-system-action-type-2',
actionTypeId: 'system-action-type-2',
name: 'System action: system-action-type-2',
name: 'My system action type',
secrets: {},
config: {},
isDeprecated: false,

View file

@ -14,7 +14,7 @@ export const createSystemConnectors = (actionTypes: ActionType[]): InMemoryConne
const systemConnectors: InMemoryConnector[] = systemActionTypes.map((systemActionType) => ({
id: `system-connector-${systemActionType.id}`,
actionTypeId: systemActionType.id,
name: `System action: ${systemActionType.id}`,
name: systemActionType.name,
isMissingSecrets: false,
config: {},
secrets: {},

View file

@ -635,6 +635,379 @@ Object {
}
`;
exports[`Connector type config checks detect connector type changes for: .cases 1`] = `
Object {
"flags": Object {
"default": Object {
"special": "deep",
},
"error": [Function],
"presence": "optional",
},
"keys": Object {
"alerts": Object {
"flags": Object {
"error": [Function],
},
"items": Array [
Object {
"flags": Object {
"error": [Function],
"presence": "optional",
},
"metas": Array [
Object {
"x-oas-get-additional-properties": [Function],
},
],
"rules": Array [
Object {
"args": Object {
"key": Object {
"flags": Object {
"error": [Function],
},
"rules": Array [
Object {
"args": Object {
"method": [Function],
},
"name": "custom",
},
],
"type": "string",
},
"value": Object {
"flags": Object {
"error": [Function],
},
"type": "any",
},
},
"name": "entries",
},
Object {
"args": Object {
"method": [Function],
},
"name": "custom",
},
],
"type": "record",
},
],
"type": "array",
},
"groupingBy": Object {
"flags": Object {
"error": [Function],
},
"items": Array [
Object {
"flags": Object {
"error": [Function],
"presence": "optional",
},
"rules": Array [
Object {
"args": Object {
"method": [Function],
},
"name": "custom",
},
],
"type": "string",
},
],
"rules": Array [
Object {
"args": Object {
"limit": 0,
},
"name": "min",
},
Object {
"args": Object {
"limit": 1,
},
"name": "max",
},
],
"type": "array",
},
"maximumCasesToOpen": Object {
"flags": Object {
"default": 5,
"error": [Function],
"presence": "optional",
},
"rules": Array [
Object {
"args": Object {
"limit": 1,
},
"name": "min",
},
Object {
"args": Object {
"limit": 10,
},
"name": "max",
},
],
"type": "number",
},
"owner": Object {
"flags": Object {
"error": [Function],
},
"rules": Array [
Object {
"args": Object {
"method": [Function],
},
"name": "custom",
},
],
"type": "string",
},
"reopenClosedCases": Object {
"flags": Object {
"default": false,
"error": [Function],
"presence": "optional",
},
"type": "boolean",
},
"rule": Object {
"flags": Object {
"default": Object {
"special": "deep",
},
"error": [Function],
"presence": "optional",
},
"keys": Object {
"id": Object {
"flags": Object {
"error": [Function],
},
"rules": Array [
Object {
"args": Object {
"method": [Function],
},
"name": "custom",
},
],
"type": "string",
},
"name": Object {
"flags": Object {
"error": [Function],
},
"rules": Array [
Object {
"args": Object {
"method": [Function],
},
"name": "custom",
},
],
"type": "string",
},
"ruleUrl": Object {
"flags": Object {
"default": null,
"error": [Function],
"presence": "optional",
},
"matches": Array [
Object {
"schema": Object {
"flags": Object {
"error": [Function],
},
"rules": Array [
Object {
"args": Object {
"method": [Function],
},
"name": "custom",
},
],
"type": "string",
},
},
Object {
"schema": Object {
"allow": Array [
null,
],
"flags": Object {
"error": [Function],
"only": true,
},
"type": "any",
},
},
],
"type": "alternatives",
},
"tags": Object {
"flags": Object {
"default": Array [],
"error": [Function],
"presence": "optional",
},
"items": Array [
Object {
"flags": Object {
"error": [Function],
"presence": "optional",
},
"rules": Array [
Object {
"args": Object {
"method": [Function],
},
"name": "custom",
},
],
"type": "string",
},
],
"type": "array",
},
},
"preferences": Object {
"stripUnknown": Object {
"objects": false,
},
},
"type": "object",
},
"timeWindow": Object {
"flags": Object {
"default": "7d",
"error": [Function],
"presence": "optional",
},
"rules": Array [
Object {
"args": Object {
"method": [Function],
},
"name": "custom",
},
Object {
"args": Object {
"method": [Function],
},
"name": "custom",
},
],
"type": "string",
},
},
"preferences": Object {
"stripUnknown": Object {
"objects": false,
},
},
"type": "object",
}
`;
exports[`Connector type config checks detect connector type changes for: .cases 2`] = `
Object {
"flags": Object {
"default": Object {
"special": "deep",
},
"error": [Function],
"presence": "optional",
},
"keys": Object {},
"preferences": Object {
"stripUnknown": Object {
"objects": false,
},
},
"type": "object",
}
`;
exports[`Connector type config checks detect connector type changes for: .cases 3`] = `
Object {
"flags": Object {
"default": Object {
"special": "deep",
},
"error": [Function],
"presence": "optional",
},
"keys": Object {},
"preferences": Object {
"stripUnknown": Object {
"objects": false,
},
},
"type": "object",
}
`;
exports[`Connector type config checks detect connector type changes for: .cases 4`] = `
Object {
"flags": Object {
"default": Object {
"special": "deep",
},
"error": [Function],
"presence": "optional",
},
"keys": Object {
"subAction": Object {
"flags": Object {
"error": [Function],
},
"rules": Array [
Object {
"args": Object {
"method": [Function],
},
"name": "custom",
},
],
"type": "string",
},
"subActionParams": Object {
"flags": Object {
"default": Object {
"special": "deep",
},
"error": [Function],
"presence": "optional",
"unknown": true,
},
"keys": Object {},
"preferences": Object {
"stripUnknown": Object {
"objects": false,
},
},
"type": "object",
},
},
"preferences": Object {
"stripUnknown": Object {
"objects": false,
},
},
"type": "object",
}
`;
exports[`Connector type config checks detect connector type changes for: .cases-webhook 1`] = `
Object {
"flags": Object {

View file

@ -29,4 +29,5 @@ export const connectorTypes: string[] = [
'.bedrock',
'.d3security',
'.sentinelone',
'.cases',
];

View file

@ -263,7 +263,7 @@ describe('Actions Plugin', () => {
{
id: 'system-connector-.cases',
actionTypeId: '.cases',
name: 'System action: .cases',
name: 'Cases',
config: {},
secrets: {},
isDeprecated: false,
@ -769,7 +769,7 @@ describe('Actions Plugin', () => {
{
id: 'system-connector-.cases',
actionTypeId: '.cases',
name: 'System action: .cases',
name: 'Cases',
config: {},
secrets: {},
isDeprecated: false,

View file

@ -29,7 +29,7 @@ export const buildExecutor = <
logger: Logger;
configurationUtilities: ActionsConfigurationUtilities;
}): ExecutorType<Config, Secrets, ExecutorParams, unknown> => {
return async ({ actionId, params, config, secrets, services }) => {
return async ({ actionId, params, config, secrets, services, request }) => {
const subAction = params.subAction;
const subActionParams = params.subActionParams;
@ -40,6 +40,7 @@ export const buildExecutor = <
configurationUtilities,
logger,
services,
request,
});
const subActions = service.getSubActions();

View file

@ -21,6 +21,7 @@ import { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
import { finished } from 'stream/promises';
import { IncomingMessage } from 'http';
import { PassThrough } from 'stream';
import { KibanaRequest } from '@kbn/core-http-server';
import { assertURL } from './helpers/validators';
import { ActionsConfigurationUtilities } from '../actions_config';
import { SubAction, SubActionRequestParams } from './types';
@ -39,6 +40,7 @@ export abstract class SubActionConnector<Config, Secrets> {
private axiosInstance: AxiosInstance;
private subActions: Map<string, SubAction> = new Map();
private configurationUtilities: ActionsConfigurationUtilities;
protected readonly kibanaRequest?: KibanaRequest;
protected logger: Logger;
protected esClient: ElasticsearchClient;
protected savedObjectsClient: SavedObjectsClientContract;
@ -55,6 +57,7 @@ export abstract class SubActionConnector<Config, Secrets> {
this.esClient = params.services.scopedClusterClient;
this.configurationUtilities = params.configurationUtilities;
this.axiosInstance = axios.create();
this.kibanaRequest = params.request;
}
private normalizeURL(url: string) {

View file

@ -10,6 +10,7 @@ import type { Logger } from '@kbn/logging';
import type { LicenseType } from '@kbn/licensing-plugin/common/types';
import type { Method, AxiosRequestConfig } from 'axios';
import { KibanaRequest } from '@kbn/core-http-server';
import type { ActionsConfigurationUtilities } from '../actions_config';
import type {
ActionTypeParams,
@ -30,6 +31,7 @@ export interface ServiceParams<Config, Secrets> {
logger: Logger;
secrets: Secrets;
services: Services;
request?: KibanaRequest;
}
export type SubActionRequestParams<R> = {

View file

@ -44,6 +44,7 @@
"@kbn/core-logging-server-mocks",
"@kbn/serverless",
"@kbn/actions-types",
"@kbn/core-http-server",
"@kbn/core-test-helpers-kbn-server",
"@kbn/security-plugin-types-server"
],

View file

@ -9,7 +9,7 @@ import { ObjectType } from '@kbn/config-schema';
import type { RuleTypeParams, SanitizedRule } from '../../common';
import { CombinedSummarizedAlerts } from '../types';
type Rule = Pick<SanitizedRule<RuleTypeParams>, 'id' | 'name' | 'tags'>;
type Rule = Pick<SanitizedRule<RuleTypeParams>, 'id' | 'name' | 'tags' | 'consumer'>;
export interface ConnectorAdapterParams {
[x: string]: unknown;

View file

@ -115,6 +115,7 @@ const rule = {
uuid: '111-111',
},
],
consumer: 'test-consumer',
} as unknown as SanitizedRule<RuleTypeParams>;
const defaultExecutionParams = {
@ -2472,6 +2473,7 @@ describe('Execution Handler', () => {
id: rule.id,
name: rule.name,
tags: rule.tags,
consumer: 'test-consumer',
},
ruleUrl:
'https://example.com/s/test1/app/management/insightsAndAlerting/triggersActions/rule/1',

View file

@ -459,7 +459,7 @@ export class ExecutionHandler<
const connectorAdapterActionParams = connectorAdapter.buildActionParams({
alerts: summarizedAlerts,
rule: { id: rule.id, tags: rule.tags, name: rule.name },
rule: { id: rule.id, tags: rule.tags, name: rule.name, consumer: rule.consumer },
ruleUrl: ruleUrl?.absoluteUrl,
spaceId,
params: action.params,

View file

@ -24,6 +24,7 @@ export const CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT = 'cases-connector-mappings' a
export const CASE_USER_ACTION_SAVED_OBJECT = 'cases-user-actions' as const;
export const CASE_COMMENT_SAVED_OBJECT = 'cases-comments' as const;
export const CASE_CONFIGURE_SAVED_OBJECT = 'cases-configure' as const;
export const CASE_RULES_SAVED_OBJECT = 'cases-rules' as const;
/**
* If more values are added here please also add them here: x-pack/test/cases_api_integration/common/plugins
@ -210,7 +211,15 @@ export const LOCAL_STORAGE_KEYS = {
* Connectors
*/
export enum CASES_CONNECTOR_SUB_ACTION {
RUN = 'run',
}
export const NONE_CONNECTOR_ID: string = 'none';
export const CASES_CONNECTOR_ID = '.cases';
export const CASES_CONNECTOR_TITLE = 'Cases';
export const CASES_CONNECTOR_TIME_WINDOW_REGEX = '^[1-9][0-9]*[d,w]$';
/**
* This field is used for authorization of the entities within the cases plugin. Each entity within Cases will have the owner field

View file

@ -0,0 +1,26 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { AlertConsumers } from '@kbn/rule-data-utils';
import { OWNER_INFO } from './owners';
describe('OWNER_INFO', () => {
it('should use all available rule consumers', () => {
const allConsumers = new Set(Object.values(AlertConsumers));
const ownersMappingConsumers = new Set(
Object.values(OWNER_INFO)
.map((value) => value.validRuleConsumers ?? [])
.flat()
);
expect(allConsumers.size).toEqual(ownersMappingConsumers.size);
for (const consumer of allConsumers) {
expect(ownersMappingConsumers.has(consumer)).toBe(true);
}
});
});

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { AlertConsumers } from '@kbn/rule-data-utils';
import { APP_ID } from './application';
import type { Owner } from './types';
@ -23,6 +24,7 @@ interface RouteInfo {
label: string;
iconType: string;
appRoute: string;
validRuleConsumers?: readonly AlertConsumers[];
}
export const OWNER_INFO: Record<Owner, RouteInfo> = {
@ -32,6 +34,7 @@ export const OWNER_INFO: Record<Owner, RouteInfo> = {
label: 'Security',
iconType: 'logoSecurity',
appRoute: '/app/security',
validRuleConsumers: [AlertConsumers.SIEM],
},
[OBSERVABILITY_OWNER]: {
id: OBSERVABILITY_OWNER,
@ -39,6 +42,16 @@ export const OWNER_INFO: Record<Owner, RouteInfo> = {
label: 'Observability',
iconType: 'logoObservability',
appRoute: '/app/observability',
validRuleConsumers: [
// only valid in serverless
AlertConsumers.OBSERVABILITY,
AlertConsumers.APM,
AlertConsumers.INFRASTRUCTURE,
AlertConsumers.LOGS,
AlertConsumers.SLO,
AlertConsumers.UPTIME,
AlertConsumers.MONITORING,
],
},
[GENERAL_CASES_OWNER]: {
id: GENERAL_CASES_OWNER,
@ -46,5 +59,6 @@ export const OWNER_INFO: Record<Owner, RouteInfo> = {
label: 'Stack',
iconType: 'casesApp',
appRoute: '/app/management/insightsAndAlerting',
validRuleConsumers: [AlertConsumers.ML, AlertConsumers.STACK_ALERTS, AlertConsumers.EXAMPLE],
},
} as const;

View file

@ -12,6 +12,7 @@
"cases"
],
"requiredPlugins": [
"alerting",
"actions",
"data",
"embeddable",

View file

@ -0,0 +1,86 @@
/*
* 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 { ActionTypeModel } from '@kbn/triggers-actions-ui-plugin/public/types';
import { getConnectorType } from './cases';
const CONNECTOR_TYPE_ID = '.cases';
let connectorTypeModel: ActionTypeModel;
beforeAll(() => {
connectorTypeModel = getConnectorType();
});
describe('has correct connector id', () => {
test('connector type static data is as expected', () => {
expect(connectorTypeModel.id).toEqual(CONNECTOR_TYPE_ID);
});
});
describe('action params validation', () => {
test('action params validation succeeds when action params is valid', async () => {
const actionParams = {
subActionParams: {
timeWindow: '7d',
reopenClosedCases: false,
groupingBy: [],
owner: 'cases',
},
};
expect(await connectorTypeModel.validateParams(actionParams)).toEqual({
errors: { timeWindow: [] },
});
});
test('params validation succeeds when valid timeWindow', async () => {
const actionParams = { subActionParams: { timeWindow: '17w' } };
expect(await connectorTypeModel.validateParams(actionParams)).toEqual({
errors: { timeWindow: [] },
});
});
test('params validation fails when timeWindow is empty', async () => {
const actionParams = { subActionParams: { timeWindow: '' } };
expect(await connectorTypeModel.validateParams(actionParams)).toEqual({
errors: { timeWindow: ['Invalid time window.'] },
});
});
test('params validation fails when timeWindow is undefined', async () => {
const actionParams = { subActionParams: { timeWindow: undefined } };
expect(await connectorTypeModel.validateParams(actionParams)).toEqual({
errors: { timeWindow: ['Invalid time window.'] },
});
});
test('params validation fails when timeWindow is null', async () => {
const actionParams = { subActionParams: { timeWindow: null } };
expect(await connectorTypeModel.validateParams(actionParams)).toEqual({
errors: { timeWindow: ['Invalid time window.'] },
});
});
test('params validation fails when timeWindow size is 0', async () => {
const actionParams = { subActionParams: { timeWindow: '0d' } };
expect(await connectorTypeModel.validateParams(actionParams)).toEqual({
errors: { timeWindow: ['Invalid time window.'] },
});
});
test('params validation fails when timeWindow size is negative', async () => {
const actionParams = { subActionParams: { timeWindow: '-5w' } };
expect(await connectorTypeModel.validateParams(actionParams)).toEqual({
errors: { timeWindow: ['Invalid time window.'] },
});
});
});

View file

@ -0,0 +1,58 @@
/*
* 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 { lazy } from 'react';
import type {
GenericValidationResult,
ActionTypeModel as ConnectorTypeModel,
} from '@kbn/triggers-actions-ui-plugin/public';
import {
CASES_CONNECTOR_ID,
CASES_CONNECTOR_TITLE,
CASES_CONNECTOR_TIME_WINDOW_REGEX,
} from '../../../../common/constants';
import type { CasesActionParams } from './types';
import * as i18n from './translations';
interface ValidationErrors {
timeWindow: string[];
}
export function getConnectorType(): ConnectorTypeModel<{}, {}, CasesActionParams> {
return {
id: CASES_CONNECTOR_ID,
iconClass: 'casesApp',
selectMessage: i18n.CASE_ACTION_DESC,
actionTypeTitle: CASES_CONNECTOR_TITLE,
actionConnectorFields: null,
isExperimental: true,
validateParams: async (
actionParams: CasesActionParams
): Promise<GenericValidationResult<unknown>> => {
const errors: ValidationErrors = {
timeWindow: [],
};
const validationResult = {
errors,
};
const timeWindowRegex = new RegExp(CASES_CONNECTOR_TIME_WINDOW_REGEX, 'g');
if (
actionParams.subActionParams &&
(!actionParams.subActionParams.timeWindow ||
!actionParams.subActionParams.timeWindow.length ||
!timeWindowRegex.test(actionParams.subActionParams.timeWindow))
) {
errors.timeWindow.push(i18n.TIME_WINDOW_SIZE_ERROR);
}
return validationResult;
},
actionParamsFields: lazy(() => import('./cases_params')),
isSystemActionType: true,
};
}

View file

@ -0,0 +1,233 @@
/*
* 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 React from 'react';
import type { ActionConnector } from '@kbn/triggers-actions-ui-plugin/public/types';
import { fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useApplication } from '../../../common/lib/kibana/use_application';
import { useAlertDataViews } from '../hooks/use_alert_data_view';
import { CasesParamsFields } from './cases_params';
import { showEuiComboBoxOptions } from '@elastic/eui/lib/test/rtl';
jest.mock('@kbn/triggers-actions-ui-plugin/public/common/lib/kibana');
jest.mock('../../../common/lib/kibana/use_application');
jest.mock('../hooks/use_alert_data_view');
const useAlertDataViewsMock = useAlertDataViews as jest.Mock;
const useApplicationMock = useApplication as jest.Mock;
const actionParams = {
subAction: 'run',
subActionParams: {
timeWindow: '6w',
reopenClosedCases: false,
groupingBy: [],
},
};
const connector: ActionConnector = {
id: 'test',
actionTypeId: '.test',
name: 'Test',
isPreconfigured: false,
isDeprecated: false,
isSystemAction: true as const,
};
const editAction = jest.fn();
const defaultProps = {
actionConnector: connector,
actionParams,
editAction,
errors: { 'subActionParams.timeWindow.size': [] },
index: 0,
producerId: 'test',
};
describe('CasesParamsFields renders', () => {
beforeEach(() => {
jest.clearAllMocks();
useApplicationMock.mockReturnValueOnce({ appId: 'management' });
useAlertDataViewsMock.mockReturnValue({
loading: false,
dataViews: [
{
title: '.alerts-test',
fields: [
{
name: 'host.ip',
type: 'ip',
aggregatable: true,
},
{
name: 'host.geo.location',
type: 'geo_point',
},
],
},
],
});
});
it('all params fields are rendered', async () => {
render(<CasesParamsFields {...defaultProps} />);
expect(await screen.findByTestId('group-by-alert-field-combobox')).toBeInTheDocument();
expect(await screen.findByTestId('time-window-size-input')).toBeInTheDocument();
expect(await screen.findByTestId('time-window-unit-select')).toBeInTheDocument();
expect(await screen.findByTestId('reopen-case')).toBeInTheDocument();
});
it('renders loading state of grouping by fields correctly', async () => {
useAlertDataViewsMock.mockReturnValue({ loading: true });
render(<CasesParamsFields {...defaultProps} />);
expect(await screen.findByRole('progressbar')).toBeInTheDocument();
});
it('disables dropdown when loading grouping by fields', async () => {
useAlertDataViewsMock.mockReturnValue({ loading: true });
render(<CasesParamsFields {...defaultProps} />);
expect(await screen.findByRole('progressbar')).toBeInTheDocument();
expect(await screen.findByTestId('comboBoxSearchInput')).toBeDisabled();
});
it('when subAction undefined, sets to default', () => {
const newProps = {
...defaultProps,
actionParams: {},
};
render(<CasesParamsFields {...newProps} />);
expect(editAction.mock.calls[0][1]).toEqual('run');
});
it('when subActionParams undefined, sets to default', () => {
const newProps = {
...defaultProps,
actionParams: {
subAction: 'run',
},
};
render(<CasesParamsFields {...newProps} />);
expect(editAction.mock.calls[0][1]).toEqual({
timeWindow: '7d',
reopenClosedCases: false,
groupingBy: [],
});
});
it('If timeWindow has errors, form row is invalid', async () => {
const newProps = {
...defaultProps,
errors: { timeWindow: ['error'] },
};
render(<CasesParamsFields {...newProps} />);
expect(await screen.findByText('error')).toBeInTheDocument();
});
describe('UI updates', () => {
it('renders grouping by field options', async () => {
render(<CasesParamsFields {...defaultProps} />);
userEvent.click(await screen.findByTestId('group-by-alert-field-combobox'));
await showEuiComboBoxOptions();
expect(await screen.findByText('host.ip')).toBeInTheDocument();
expect(screen.queryByText('host.geo.location')).not.toBeInTheDocument();
});
it('updates grouping by field', async () => {
render(<CasesParamsFields {...defaultProps} />);
userEvent.click(await screen.findByTestId('group-by-alert-field-combobox'));
await showEuiComboBoxOptions();
expect(await screen.findByText('host.ip')).toBeInTheDocument();
userEvent.click(await screen.findByText('host.ip'));
expect(editAction.mock.calls[0][1].groupingBy).toEqual(['host.ip']);
});
it('updates grouping by field by search', async () => {
useAlertDataViewsMock.mockReturnValue({
loading: false,
dataViews: [
{
title: '.alerts-test',
fields: [
{
name: 'host.ip',
type: 'ip',
aggregatable: true,
},
{
name: 'host.geo.location',
type: 'geo_point',
},
{
name: 'alert.name',
type: 'string',
aggregatable: true,
},
],
},
],
});
render(<CasesParamsFields {...defaultProps} />);
userEvent.click(await screen.findByTestId('group-by-alert-field-combobox'));
await showEuiComboBoxOptions();
userEvent.type(await screen.findByTestId('comboBoxSearchInput'), 'alert.name{enter}');
expect(editAction.mock.calls[0][1].groupingBy).toEqual(['alert.name']);
});
it('updates time window size', async () => {
render(<CasesParamsFields {...defaultProps} />);
expect(await screen.findByTestId('time-window-size-input')).toBeInTheDocument();
userEvent.clear(await screen.findByTestId('time-window-size-input'));
userEvent.paste(await screen.findByTestId('time-window-size-input'), '5');
expect(editAction.mock.calls[0][1].timeWindow).toEqual('5w');
});
it('updates time window unit', async () => {
render(<CasesParamsFields {...defaultProps} />);
expect(await screen.findByTestId('time-window-unit-select')).toBeInTheDocument();
fireEvent.change(await screen.findByTestId('time-window-unit-select'), {
target: { value: 'd' },
});
expect(editAction.mock.calls[0][1].timeWindow).toEqual('6d');
});
it('updates reopenClosedCases', async () => {
render(<CasesParamsFields {...defaultProps} />);
expect(await screen.findByTestId('reopen-case')).toBeInTheDocument();
userEvent.click(await screen.findByTestId('reopen-case'));
expect(editAction.mock.calls[0][1].reopenClosedCases).toEqual(true);
});
});
});

View file

@ -0,0 +1,210 @@
/*
* 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 React, { memo, useCallback, useEffect, useMemo } from 'react';
import type { ActionParamsProps } from '@kbn/triggers-actions-ui-plugin/public/types';
import type { EuiComboBoxOptionOption } from '@elastic/eui';
import {
EuiCheckbox,
EuiFieldNumber,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiSelect,
EuiSpacer,
EuiComboBox,
} from '@elastic/eui';
import type { ValidFeatureId } from '@kbn/rule-data-utils';
import { CASES_CONNECTOR_SUB_ACTION } from '../../../../common/constants';
import * as i18n from './translations';
import type { CasesActionParams } from './types';
import { DEFAULT_TIME_WINDOW, TIME_UNITS } from './constants';
import { getTimeUnitOptions } from './utils';
import { useAlertDataViews } from '../hooks/use_alert_data_view';
export const CasesParamsFieldsComponent: React.FunctionComponent<
ActionParamsProps<CasesActionParams>
> = ({ actionParams, editAction, errors, index, producerId }) => {
const { dataViews, loading: loadingAlertDataViews } = useAlertDataViews(
producerId ? [producerId as ValidFeatureId] : []
);
const { timeWindow, reopenClosedCases, groupingBy } = useMemo(
() =>
actionParams.subActionParams ?? {
timeWindow: `${DEFAULT_TIME_WINDOW}`,
reopenClosedCases: false,
groupingBy: [],
},
[actionParams.subActionParams]
);
const parsedTimeWindowSize = timeWindow.slice(0, timeWindow.length - 1);
const parsedTimeWindowUnit = timeWindow.slice(-1);
const timeWindowSize = isNaN(parseInt(parsedTimeWindowSize, 10))
? DEFAULT_TIME_WINDOW[0]
: parsedTimeWindowSize.toString();
const timeWindowUnit = Object.values(TIME_UNITS).includes(parsedTimeWindowUnit as TIME_UNITS)
? parsedTimeWindowUnit
: DEFAULT_TIME_WINDOW[1];
useEffect(() => {
if (!actionParams.subAction) {
editAction('subAction', CASES_CONNECTOR_SUB_ACTION.RUN, index);
}
if (!actionParams.subActionParams) {
editAction(
'subActionParams',
{
timeWindow: `${DEFAULT_TIME_WINDOW}`,
reopenClosedCases: false,
groupingBy: [],
},
index
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [actionParams]);
const editSubActionProperty = useCallback(
(key: string, value: unknown) => {
return editAction(
'subActionParams',
{ ...actionParams.subActionParams, [key]: value },
index
);
},
[editAction, index, actionParams.subActionParams]
);
const handleTimeWindowChange = useCallback(
(key: 'timeWindowSize' | 'timeWindowUnit', value: string) => {
if (!value) {
return;
}
const newTimeWindow =
key === 'timeWindowSize' ? `${value}${timeWindowUnit}` : `${timeWindowSize}${value}`;
editSubActionProperty('timeWindow', newTimeWindow);
},
[editSubActionProperty, timeWindowUnit, timeWindowSize]
);
const onChangeComboBox = useCallback(
(optionsValue: Array<EuiComboBoxOptionOption<string>>) => {
editSubActionProperty('groupingBy', optionsValue?.length ? [optionsValue[0].value] : []);
},
[editSubActionProperty]
);
const options: Array<EuiComboBoxOptionOption<string>> = useMemo(() => {
if (!dataViews?.length) {
return [];
}
return dataViews
.map((dataView) => {
return dataView.fields
.filter((field) => Boolean(field.aggregatable))
.map((field) => ({
value: field.name,
label: field.name,
}));
})
.flat();
}, [dataViews]);
const selectedOptions = groupingBy.map((field) => ({ value: field, label: field }));
return (
<>
<EuiFlexGroup>
<EuiFlexItem grow={true}>
<EuiFormRow fullWidth>
<EuiComboBox
fullWidth
isClearable={true}
singleSelection
data-test-subj="group-by-alert-field-combobox"
prepend={i18n.GROUP_BY_ALERT}
isLoading={loadingAlertDataViews}
isDisabled={loadingAlertDataViews}
options={options}
onChange={onChangeComboBox}
selectedOptions={selectedOptions}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiFormRow
fullWidth
id="timeWindow"
error={errors.timeWindow}
isInvalid={
errors.timeWindow !== undefined &&
errors.timeWindow.length > 0 &&
timeWindow !== undefined
}
>
<EuiFlexGroup alignItems="flexEnd" gutterSize="s">
<EuiFlexItem grow={4}>
<EuiFieldNumber
prepend={i18n.TIME_WINDOW}
data-test-subj="time-window-size-input"
value={timeWindowSize}
min={1}
step={1}
onChange={(e) => {
handleTimeWindowChange('timeWindowSize', e.target.value);
}}
/>
</EuiFlexItem>
<EuiFlexItem grow={3}>
<EuiSelect
fullWidth
data-test-subj="time-window-unit-select"
value={timeWindowUnit}
onChange={(e) => {
handleTimeWindowChange('timeWindowUnit', e.target.value);
}}
options={getTimeUnitOptions(timeWindowSize)}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFormRow>
<EuiSpacer size="m" />
<EuiFlexGroup>
<EuiFlexItem>
<EuiCheckbox
id={`reopen-case-${index}`}
data-test-subj="reopen-case"
checked={reopenClosedCases}
label={i18n.REOPEN_WHEN_CASE_IS_CLOSED}
onChange={(e) => {
editSubActionProperty('reopenClosedCases', e.target.checked);
}}
/>
</EuiFlexItem>
</EuiFlexGroup>
</>
);
};
CasesParamsFieldsComponent.displayName = 'CasesParamsFields';
export const CasesParamsFields = memo(CasesParamsFieldsComponent);
// default export required for React.Lazy
// eslint-disable-next-line import/no-default-export
export { CasesParamsFields as default };

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const DEFAULT_TIME_WINDOW = '7d';
export enum TIME_UNITS {
DAYS = 'd',
WEEKS = 'w',
}

View file

@ -0,0 +1,67 @@
/*
* 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';
export const CASE_ACTION_DESC = i18n.translate(
'xpack.cases.systemActions.casesConnector.selectMessageText',
{
defaultMessage: 'Create a case in Kibana.',
}
);
export const GROUP_BY_ALERT = i18n.translate(
'xpack.cases.systemActions.casesConnector.groupByLabel',
{
defaultMessage: 'Group by alert field',
}
);
export const TIME_WINDOW = i18n.translate(
'xpack.cases.systemActions.casesConnector.timeWindowLabel',
{
defaultMessage: 'Time window',
}
);
export const TIME_WINDOW_SIZE_ERROR = i18n.translate(
'xpack.cases.systemActions.casesConnector.timeWindowSizeError',
{
defaultMessage: 'Invalid time window.',
}
);
export const REOPEN_WHEN_CASE_IS_CLOSED = i18n.translate(
'xpack.cases.systemActions.casesConnector.reopenWhenCaseIsClosed',
{
defaultMessage: 'Reopen when the case is closed',
}
);
export const DAYS = (timeValue: string) =>
i18n.translate('xpack.cases.systemActions.casesConnector.daysLabel', {
defaultMessage: '{timeValue, plural, one {day} other {days}}',
values: { timeValue },
});
export const YEARS = (timeValue: string) =>
i18n.translate('xpack.cases.systemActions.casesConnector.yearsLabel', {
defaultMessage: '{timeValue, plural, one {year} other {years}}',
values: { timeValue },
});
export const MONTHS = (timeValue: string) =>
i18n.translate('xpack.cases.systemActions.casesConnector.monthsLabel', {
defaultMessage: '{timeValue, plural, one {month} other {months}}',
values: { timeValue },
});
export const WEEKS = (timeValue: string) =>
i18n.translate('xpack.cases.systemActions.casesConnector.weeksLabel', {
defaultMessage: '{timeValue, plural, one {week} other {weeks}}',
values: { timeValue },
});

View file

@ -0,0 +1,16 @@
/*
* 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 interface CasesSubActionParamsUI {
timeWindow: string;
reopenClosedCases: boolean;
groupingBy: string[];
}
export interface CasesActionParams {
subAction: string;
subActionParams: CasesSubActionParamsUI;
}

View file

@ -0,0 +1,50 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { getTimeUnitOptions } from './utils';
describe('getTimeUnitOptions', () => {
test('return single unit time options', () => {
const timeUnitValue = getTimeUnitOptions('1');
expect(timeUnitValue).toMatchObject([
{ text: 'day', value: 'd' },
{ text: 'week', value: 'w' },
]);
});
test('return multiple unit time options', () => {
const timeUnitValue = getTimeUnitOptions('10');
expect(timeUnitValue).toMatchObject([
{ text: 'days', value: 'd' },
{ text: 'weeks', value: 'w' },
]);
});
test('return correct unit time options for 0', () => {
const timeUnitValue = getTimeUnitOptions('0');
expect(timeUnitValue).toMatchObject([
{ text: 'days', value: 'd' },
{ text: 'weeks', value: 'w' },
]);
});
test('return correct unit time options for negative size', () => {
const timeUnitValue = getTimeUnitOptions('-5');
expect(timeUnitValue).toMatchObject([
{ text: 'days', value: 'd' },
{ text: 'weeks', value: 'w' },
]);
});
test('return correct unit time options for empty string', () => {
const timeUnitValue = getTimeUnitOptions('');
expect(timeUnitValue).toMatchObject([
{ text: 'days', value: 'd' },
{ text: 'weeks', value: 'w' },
]);
});
});

View file

@ -0,0 +1,27 @@
/*
* 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 { TIME_UNITS } from './constants';
import * as i18n from './translations';
export const getTimeUnitOptions = (unitSize: string) => {
return Object.entries(TIME_UNITS).map(([_key, value]) => {
return {
text: getTimeUnitLabels(value, unitSize === '' ? '0' : unitSize),
value,
};
});
};
export const getTimeUnitLabels = (timeUnit = TIME_UNITS.DAYS, timeValue = '0') => {
switch (timeUnit) {
case TIME_UNITS.DAYS:
return i18n.DAYS(timeValue);
case TIME_UNITS.WEEKS:
return i18n.WEEKS(timeValue);
}
};

View file

@ -0,0 +1,27 @@
/*
* 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 { ValidFeatureId } from '@kbn/rule-data-utils';
import type { HttpSetup } from '@kbn/core/public';
import type { FieldSpec } from '@kbn/data-views-plugin/common';
import { BASE_RAC_ALERTS_API_PATH } from '@kbn/rule-registry-plugin/common';
export async function fetchAlertFields({
http,
featureIds,
}: {
http: HttpSetup;
featureIds: ValidFeatureId[];
}): Promise<FieldSpec[]> {
const { fields: alertFields = [] } = await http.get<{ fields: FieldSpec[] }>(
`${BASE_RAC_ALERTS_API_PATH}/browser_fields`,
{
query: { featureIds },
}
);
return alertFields;
}

View file

@ -0,0 +1,25 @@
/*
* 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 { BASE_RAC_ALERTS_API_PATH } from '@kbn/rule-registry-plugin/common';
import type { HttpSetup } from '@kbn/core/public';
export async function fetchAlertIndexNames({
http,
features,
}: {
http: HttpSetup;
features: string;
}): Promise<string[]> {
const { index_name: indexNamesStr = [] } = await http.get<{ index_name: string[] }>(
`${BASE_RAC_ALERTS_API_PATH}/index`,
{
query: { features },
}
);
return indexNamesStr;
}

View file

@ -0,0 +1,135 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { waitFor } from '@testing-library/react';
import { renderHook } from '@testing-library/react-hooks/dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AlertConsumers } from '@kbn/rule-data-utils';
import type { ValidFeatureId } from '@kbn/rule-data-utils';
import { createStartServicesMock } from '../../../common/lib/kibana/kibana_react.mock';
import { useAlertDataViews } from './use_alert_data_view';
const mockUseKibanaReturnValue = createStartServicesMock();
jest.mock('@kbn/kibana-react-plugin/public', () => ({
__esModule: true,
useKibana: jest.fn(() => ({
services: mockUseKibanaReturnValue,
})),
}));
jest.mock('./alert_index', () => ({
fetchAlertIndexNames: jest.fn(),
}));
const { fetchAlertIndexNames } = jest.requireMock('./alert_index');
jest.mock('./alert_fields', () => ({
fetchAlertFields: jest.fn(),
}));
const { fetchAlertFields } = jest.requireMock('./alert_fields');
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
cacheTime: 0,
},
},
});
const wrapper = ({ children }: { children: Node }) => (
<QueryClientProvider client={queryClient}> {children} </QueryClientProvider>
);
describe('useAlertDataView', () => {
const observabilityAlertFeatureIds: ValidFeatureId[] = [
AlertConsumers.APM,
AlertConsumers.INFRASTRUCTURE,
AlertConsumers.LOGS,
AlertConsumers.UPTIME,
];
beforeEach(() => {
fetchAlertIndexNames.mockResolvedValue([
'.alerts-observability.uptime.alerts-*',
'.alerts-observability.metrics.alerts-*',
'.alerts-observability.logs.alerts-*',
'.alerts-observability.apm.alerts-*',
]);
fetchAlertFields.mockResolvedValue([{ data: ' fields' }]);
});
afterEach(() => {
queryClient.clear();
jest.clearAllMocks();
});
it('initially is loading and does not have data', async () => {
const mockedAsyncDataView = {
loading: true,
dataview: undefined,
};
const { result } = renderHook(() => useAlertDataViews(observabilityAlertFeatureIds), {
wrapper,
});
await waitFor(() => expect(result.current).toEqual(mockedAsyncDataView));
});
it('fetch index names + fields for the provided o11y featureIds', async () => {
renderHook(() => useAlertDataViews(observabilityAlertFeatureIds), {
wrapper,
});
await waitFor(() => expect(fetchAlertIndexNames).toHaveBeenCalledTimes(1));
expect(fetchAlertFields).toHaveBeenCalledTimes(1);
});
it('only fetch index names for security featureId', async () => {
renderHook(() => useAlertDataViews([AlertConsumers.SIEM]), {
wrapper,
});
await waitFor(() => expect(fetchAlertIndexNames).toHaveBeenCalledTimes(1));
expect(fetchAlertFields).toHaveBeenCalledTimes(0);
});
it('Do not fetch anything if security and o11y featureIds are mixed together', async () => {
const { result } = renderHook(
() => useAlertDataViews([AlertConsumers.SIEM, AlertConsumers.LOGS]),
{
wrapper,
}
);
await waitFor(() =>
expect(result.current).toEqual({
loading: false,
dataview: undefined,
})
);
expect(fetchAlertIndexNames).toHaveBeenCalledTimes(0);
expect(fetchAlertFields).toHaveBeenCalledTimes(0);
});
it('if fetch throws error return no data', async () => {
fetchAlertIndexNames.mockRejectedValue('error');
const { result } = renderHook(() => useAlertDataViews(observabilityAlertFeatureIds), {
wrapper,
});
await waitFor(() =>
expect(result.current).toEqual({
loading: false,
dataview: undefined,
})
);
});
});

View file

@ -0,0 +1,161 @@
/*
* 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 type { DataView } from '@kbn/data-views-plugin/common';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import type { ValidFeatureId } from '@kbn/rule-data-utils';
import { AlertConsumers } from '@kbn/rule-data-utils';
import { useEffect, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import type { TriggersAndActionsUiServices } from '@kbn/triggers-actions-ui-plugin/public';
import { fetchAlertIndexNames } from './alert_index';
import { fetchAlertFields } from './alert_fields';
export interface UserAlertDataViews {
dataViews?: DataView[];
loading: boolean;
}
export function useAlertDataViews(featureIds: ValidFeatureId[]): UserAlertDataViews {
const {
http,
data: dataService,
notifications: { toasts },
} = useKibana<TriggersAndActionsUiServices>().services;
const [dataViews, setDataViews] = useState<DataView[] | undefined>(undefined);
const features = featureIds.sort().join(',');
const isOnlySecurity = featureIds.length === 1 && featureIds.includes(AlertConsumers.SIEM);
const hasSecurityAndO11yFeatureIds =
featureIds.length > 1 && featureIds.includes(AlertConsumers.SIEM);
const hasNoSecuritySolution =
featureIds.length > 0 && !isOnlySecurity && !hasSecurityAndO11yFeatureIds;
const queryIndexNameFn = () => {
return fetchAlertIndexNames({ http, features });
};
const queryAlertFieldsFn = () => {
return fetchAlertFields({ http, featureIds });
};
const onErrorFn = () => {
toasts.addDanger(
i18n.translate('xpack.cases.systemActions.useAlertDataView.useAlertDataMessage', {
defaultMessage: 'Unable to load alert data view',
})
);
};
const {
data: indexNames,
isSuccess: isIndexNameSuccess,
isInitialLoading: isIndexNameInitialLoading,
isLoading: isIndexNameLoading,
} = useQuery({
queryKey: ['loadAlertIndexNames', features],
queryFn: queryIndexNameFn,
onError: onErrorFn,
refetchOnWindowFocus: false,
enabled: featureIds.length > 0 && !hasSecurityAndO11yFeatureIds,
});
const {
data: alertFields,
isSuccess: isAlertFieldsSuccess,
isInitialLoading: isAlertFieldsInitialLoading,
isLoading: isAlertFieldsLoading,
} = useQuery({
queryKey: ['loadAlertFields', features],
queryFn: queryAlertFieldsFn,
onError: onErrorFn,
refetchOnWindowFocus: false,
enabled: hasNoSecuritySolution,
});
useEffect(() => {
return () => {
dataViews?.map((dv) => dataService.dataViews.clearInstanceCache(dv.id));
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dataViews]);
// FUTURE ENGINEER this useEffect is for security solution user since
// we are using the user privilege to access the security alert index
useEffect(() => {
async function createDataView() {
const localDataview = await dataService?.dataViews.create({
title: (indexNames ?? []).join(','),
allowNoIndex: true,
});
setDataViews([localDataview]);
}
if (isOnlySecurity && isIndexNameSuccess) {
createDataView();
}
}, [dataService?.dataViews, indexNames, isIndexNameSuccess, isOnlySecurity]);
// FUTURE ENGINEER this useEffect is for o11y and stack solution user since
// we are using the kibana user privilege to access the alert index
useEffect(() => {
if (
indexNames &&
alertFields &&
!isOnlySecurity &&
isAlertFieldsSuccess &&
isIndexNameSuccess
) {
setDataViews([
{
title: (indexNames ?? []).join(','),
fieldFormatMap: {},
fields: (alertFields ?? [])?.map((field) => {
return {
...field,
...(field.esTypes && field.esTypes.includes('flattened') ? { type: 'string' } : {}),
};
}),
},
] as unknown as DataView[]);
}
}, [
alertFields,
dataService?.dataViews,
indexNames,
isIndexNameSuccess,
isOnlySecurity,
isAlertFieldsSuccess,
]);
return useMemo(
() => ({
dataViews,
loading:
featureIds.length === 0 || hasSecurityAndO11yFeatureIds
? false
: isOnlySecurity
? isIndexNameInitialLoading || isIndexNameLoading
: isIndexNameInitialLoading ||
isIndexNameLoading ||
isAlertFieldsInitialLoading ||
isAlertFieldsLoading,
}),
[
dataViews,
featureIds.length,
hasSecurityAndO11yFeatureIds,
isOnlySecurity,
isIndexNameInitialLoading,
isIndexNameLoading,
isAlertFieldsInitialLoading,
isAlertFieldsLoading,
]
);
}

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 type { TriggersAndActionsUIPublicPluginSetup } from '@kbn/triggers-actions-ui-plugin/public';
import { getConnectorType } from './cases/cases';
export const registerSystemActions = (triggersActionsUi: TriggersAndActionsUIPublicPluginSetup) =>
triggersActionsUi.actionTypeRegistry.register(getConnectorType());

View file

@ -52,6 +52,7 @@ describe('Cases Ui Plugin', () => {
},
security: securityMock.createSetup(),
management: managementPluginMock.createSetupContract(),
triggersActionsUi: triggersActionsUiMock.createStart(),
};
pluginsStart = {

View file

@ -36,6 +36,7 @@ import type {
CasesPublicSetupDependencies,
CasesPublicStartDependencies,
} from './types';
import { registerSystemActions } from './components/system_actions';
/**
* @public
@ -113,6 +114,8 @@ export class CasesUiPlugin
});
}
registerSystemActions(plugins.triggersActionsUi);
return {
attachmentFramework: {
registerExternalReference: (externalReferenceAttachmentType) => {

View file

@ -18,7 +18,10 @@ import type { FeaturesPluginStart } from '@kbn/features-plugin/public';
import type { LensPublicStart } from '@kbn/lens-plugin/public';
import type { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin/public';
import type { SpacesPluginStart } from '@kbn/spaces-plugin/public';
import type { TriggersAndActionsUIPublicPluginStart as TriggersActionsStart } from '@kbn/triggers-actions-ui-plugin/public';
import type {
TriggersAndActionsUIPublicPluginSetup as TriggersActionsSetup,
TriggersAndActionsUIPublicPluginStart as TriggersActionsStart,
} from '@kbn/triggers-actions-ui-plugin/public';
import type { DistributiveOmit } from '@elastic/eui';
import type { ApmBase } from '@elastic/apm-rum';
import type { LicensingPluginStart } from '@kbn/licensing-plugin/public';
@ -64,6 +67,7 @@ export interface CasesPublicSetupDependencies {
serverless?: ServerlessPluginSetup;
management: ManagementSetup;
home?: HomePublicPluginSetup;
triggersActionsUi: TriggersActionsSetup;
}
export interface CasesPublicStartDependencies {

View file

@ -19,7 +19,7 @@ import {
} from '../../../common/constants';
import { mockCases } from '../../mocks';
import { createCasesClientMock, createCasesClientMockArgs } from '../mocks';
import { update } from './update';
import { bulkUpdate } from './bulk_update';
describe('update', () => {
const cases = {
@ -55,7 +55,7 @@ describe('update', () => {
});
it('notifies an assignee', async () => {
await update(cases, clientArgs, casesClientMock);
await bulkUpdate(cases, clientArgs, casesClientMock);
expect(clientArgs.services.notificationService.bulkNotifyAssignees).toHaveBeenCalledWith([
{
@ -72,7 +72,7 @@ describe('update', () => {
expect.assertions(2);
await expect(
update(
bulkUpdate(
{
cases: [
{
@ -104,7 +104,7 @@ describe('update', () => {
],
});
await expect(update(cases, clientArgs, casesClientMock)).rejects.toThrow(
await expect(bulkUpdate(cases, clientArgs, casesClientMock)).rejects.toThrow(
'Failed to update case, ids: [{"id":"mock-id-1","version":"WzAsMV0="}]: Error: All update fields are identical to current version.'
);
@ -130,7 +130,7 @@ describe('update', () => {
],
});
await update(
await bulkUpdate(
{
cases: [
{
@ -172,7 +172,7 @@ describe('update', () => {
saved_objects: [{ ...mockCases[0], attributes: { assignees: [{ uid: '1' }] } }],
});
await update(
await bulkUpdate(
{
cases: [
{
@ -211,7 +211,7 @@ describe('update', () => {
],
});
await update(
await bulkUpdate(
{
cases: [
{
@ -249,7 +249,7 @@ describe('update', () => {
],
});
await update(
await bulkUpdate(
{
cases: [
{
@ -273,7 +273,7 @@ describe('update', () => {
it('should throw an error when an invalid field is included in the request payload', async () => {
await expect(
update(
bulkUpdate(
{
cases: [
{
@ -297,7 +297,7 @@ describe('update', () => {
const assignees = Array(MAX_ASSIGNEES_PER_CASE + 1).fill({ uid: 'foo' });
await expect(
update(
bulkUpdate(
{
cases: [
{
@ -337,7 +337,7 @@ describe('update', () => {
});
await expect(
update(
bulkUpdate(
{
cases: [
{
@ -355,7 +355,7 @@ describe('update', () => {
it('does not update the category if the length is too long', async () => {
await expect(
update(
bulkUpdate(
{
cases: [
{
@ -375,7 +375,7 @@ describe('update', () => {
it('throws error if category is just an empty string', async () => {
await expect(
update(
bulkUpdate(
{
cases: [
{
@ -395,7 +395,7 @@ describe('update', () => {
it('throws error if category is a string with empty characters', async () => {
await expect(
update(
bulkUpdate(
{
cases: [
{
@ -414,7 +414,7 @@ describe('update', () => {
});
it('should trim category', async () => {
await update(
await bulkUpdate(
{
cases: [
{
@ -471,7 +471,7 @@ describe('update', () => {
});
await expect(
update(
bulkUpdate(
{
cases: [
{
@ -489,7 +489,7 @@ describe('update', () => {
it('throws error if the title is too long', async () => {
await expect(
update(
bulkUpdate(
{
cases: [
{
@ -510,7 +510,7 @@ describe('update', () => {
it('throws error if title is just an empty string', async () => {
await expect(
update(
bulkUpdate(
{
cases: [
{
@ -530,7 +530,7 @@ describe('update', () => {
it('throws error if title is a string with empty characters', async () => {
await expect(
update(
bulkUpdate(
{
cases: [
{
@ -549,7 +549,7 @@ describe('update', () => {
});
it('should trim title', async () => {
await update(
await bulkUpdate(
{
cases: [
{
@ -606,7 +606,7 @@ describe('update', () => {
});
await expect(
update(
bulkUpdate(
{
cases: [
{
@ -628,7 +628,7 @@ describe('update', () => {
.toString();
await expect(
update(
bulkUpdate(
{
cases: [
{
@ -648,7 +648,7 @@ describe('update', () => {
it('throws error if description is just an empty string', async () => {
await expect(
update(
bulkUpdate(
{
cases: [
{
@ -668,7 +668,7 @@ describe('update', () => {
it('throws error if description is a string with empty characters', async () => {
await expect(
update(
bulkUpdate(
{
cases: [
{
@ -687,7 +687,7 @@ describe('update', () => {
});
it('should trim description', async () => {
await update(
await bulkUpdate(
{
cases: [
{
@ -750,7 +750,7 @@ describe('update', () => {
});
await expect(
update(
bulkUpdate(
{
cases: [
{
@ -888,7 +888,7 @@ describe('update', () => {
});
await expect(
update(
bulkUpdate(
{
cases: [
{
@ -910,7 +910,7 @@ describe('update', () => {
});
await expect(
update(
bulkUpdate(
{
cases: [
{
@ -930,7 +930,7 @@ describe('update', () => {
const tags = Array(MAX_TAGS_PER_CASE + 1).fill('foo');
await expect(
update(
bulkUpdate(
{
cases: [
{
@ -954,7 +954,7 @@ describe('update', () => {
.toString();
await expect(
update(
bulkUpdate(
{
cases: [
{
@ -974,7 +974,7 @@ describe('update', () => {
it('throws error if tag is empty string', async () => {
await expect(
update(
bulkUpdate(
{
cases: [
{
@ -994,7 +994,7 @@ describe('update', () => {
it('throws error if tag is a string with empty characters', async () => {
await expect(
update(
bulkUpdate(
{
cases: [
{
@ -1013,7 +1013,7 @@ describe('update', () => {
});
it('should trim tags', async () => {
await update(
await bulkUpdate(
{
cases: [
{
@ -1106,7 +1106,7 @@ describe('update', () => {
});
await expect(
update(
bulkUpdate(
{
cases: [
{
@ -1156,7 +1156,7 @@ describe('update', () => {
});
await expect(
update(
bulkUpdate(
{
cases: [
{
@ -1213,7 +1213,7 @@ describe('update', () => {
});
await expect(
update(
bulkUpdate(
{
cases: [
{
@ -1264,7 +1264,7 @@ describe('update', () => {
});
await expect(
update(
bulkUpdate(
{
cases: [
{
@ -1284,7 +1284,7 @@ describe('update', () => {
it('throws with duplicated customFields keys', async () => {
await expect(
update(
bulkUpdate(
{
cases: [
{
@ -1315,7 +1315,7 @@ describe('update', () => {
it('throws when customFields keys are not present in configuration', async () => {
await expect(
update(
bulkUpdate(
{
cases: [
{
@ -1368,7 +1368,7 @@ describe('update', () => {
]);
await expect(
update(
bulkUpdate(
{
cases: [
{
@ -1419,7 +1419,7 @@ describe('update', () => {
]);
await expect(
update(
bulkUpdate(
{
cases: [
{
@ -1439,7 +1439,7 @@ describe('update', () => {
it('throws when the customField types dont match the configuration', async () => {
await expect(
update(
bulkUpdate(
{
cases: [
{
@ -1481,7 +1481,7 @@ describe('update', () => {
it(`throws an error when trying to update more than ${MAX_CASES_TO_UPDATE} cases`, async () => {
await expect(
update(
bulkUpdate(
{
cases: Array(MAX_CASES_TO_UPDATE + 1).fill({
id: mockCases[0].id,
@ -1499,7 +1499,7 @@ describe('update', () => {
it('throws an error when trying to update zero cases', async () => {
await expect(
update(
bulkUpdate(
{
cases: [],
},
@ -1542,7 +1542,7 @@ describe('update', () => {
});
await expect(
update(
bulkUpdate(
{
cases: [
{
@ -1571,7 +1571,7 @@ describe('update', () => {
});
await expect(
update(
bulkUpdate(
{
cases: [
{
@ -1604,7 +1604,7 @@ describe('update', () => {
});
await expect(
update(
bulkUpdate(
{
cases: [
{

View file

@ -308,7 +308,7 @@ export interface UpdateRequestWithOriginalCase {
*
* @ignore
*/
export const update = async (
export const bulkUpdate = async (
cases: CasesPatchRequest,
clientArgs: CasesClientArgs,
casesClient: CasesClient

View file

@ -32,7 +32,7 @@ import type { CasesByAlertIDParams, GetParams } from './get';
import { get, resolve, getCasesByAlertID, getReporters, getTags, getCategories } from './get';
import type { PushParams } from './push';
import { push } from './push';
import { update } from './update';
import { bulkUpdate } from './bulk_update';
import { bulkCreate } from './bulk_create';
import type { ReplaceCustomFieldArgs } from './replace_custom_field';
import { replaceCustomField } from './replace_custom_field';
@ -75,7 +75,7 @@ export interface CasesSubClient {
/**
* Update the specified cases with the passed in values.
*/
update(cases: CasesPatchRequest): Promise<Cases>;
bulkUpdate(cases: CasesPatchRequest): Promise<Cases>;
/**
* Delete a case and all its comments.
*
@ -122,7 +122,7 @@ export const createCasesSubClient = (
resolve: (params: GetParams) => resolve(params, clientArgs),
bulkGet: (params) => bulkGet(params, clientArgs),
push: (params: PushParams) => push(params, clientArgs, casesClient),
update: (cases: CasesPatchRequest) => update(cases, clientArgs, casesClient),
bulkUpdate: (cases: CasesPatchRequest) => bulkUpdate(cases, clientArgs, casesClient),
delete: (ids: string[]) => deleteCases(ids, clientArgs),
getTags: (params: AllTagsFindRequest) => getTags(params, clientArgs),
getCategories: (params: AllCategoriesFindRequest) => getCategories(params, clientArgs),

View file

@ -53,7 +53,7 @@ const createCasesSubClientMock = (): CasesSubClientMock => {
get: jest.fn(),
bulkGet: jest.fn(),
push: jest.fn(),
update: jest.fn(),
bulkUpdate: jest.fn(),
delete: jest.fn(),
getTags: jest.fn(),
getReporters: jest.fn(),

View file

@ -0,0 +1,579 @@
# Summary
The case action groups related alerts to cases automatically based on rules and conditions when alerts are detected. Specifically the case action:
1. Allows users to automatically attach alerts to a case.
2. Allows users to group alerts by a field and attach all alerts of each group to a case. Each group will be attached to its own case.
3. Allows users to define a time window specifying when alerts will be attached to an existing case instead of creating a new one based on time.
4. Allows users to configure if they want to reopen the case if it is closed.
# Architecture
## Terminology
| | |
| :--- | :-------------------------------- |
| GD | Grouping definition |
| RGD | Rule and grouping definition |
| RGDP | Rule and grouping definition pair |
## Connector adapter
## High-level flow
The case action groups all alerts based on the grouping field defined by the user. Then the case action for each group does the following:
1. Check if there is a case that already represents the specific group for the specific rule.
2. If not, it will create a new case and attach the alerts to the new case. If there is a case, then it will do the following checks:
1. Check if the case has already 1K alerts attached to it. If yes it will log a warning and terminate.
2. Check if the case is older than the defined time window. If yes it will create a new case as described in Step 1.
3. Check if the case is closed. If it is, it will check if the case should be reopened. If yes the case action will reopen the case and attach the alerts to it. If not it will create a new case and attach the alerts to the new case.
If an alert does not belong to a group it will be attached to a case representing the `unknown` value. Also, if no grouping field is configured by the user, all alerts will be attached to the same case.
```mermaid
flowchart TB
subgraph top [" "]
direction TB
startState --> alerts
alerts --> groupBy
startState((Start))
alerts[/Alerts/]
groupBy[Group alerts by field]
end
subgraph bottom [for each group]
direction LR
caseExists -->|Yes| alertsLimit
caseExists -->|No| createNewCase
alertsLimit -->|Yes| endState
alertsLimit -->|No| caseOld
caseOld -->|Yes| createNewCase
caseOld -->|No| caseClosed
caseClosed -->|Yes| shouldOpenCase
caseClosed -->|No| attachAlertsToCase
shouldOpenCase -->|Yes| openCase
shouldOpenCase -->|No| createNewCase
createNewCase --> attachAlertsToCase
openCase --> attachAlertsToCase
attachAlertsToCase --> endState
caseExists{Does the case exists for this group?}
alertsLimit{Are there more than 1K alerts to the case?}
caseOld{Is the case older than the time window?}
caseClosed{Is the case closed?}
shouldOpenCase{Should I open the case?}
createNewCase[Create new case]
attachAlertsToCase[Attach the alerts to the case]
openCase[Open case]
endState((End))
end
top --> bottom
```
## Grouping
The case action accepts an array of alerts provided by the connector adapter. Duplicate alerts will not be attached to the same case. The case action groups the alerts by the grouping field configured by the user. For example, if the grouping field is `host.name` the case action will group the alerts by the values of the `host.name` field. Users may define more than one grouping field. In this case, the grouping will be done by multiple fields. The grouping is performed in memory by the case action as the number of alerts is expected to be low on average and they are already loaded in memory by the alerting framework.
```mermaid
flowchart LR
alert1 --> groupingBy
alert2 --> groupingBy
alert3 --> groupingBy
subgraph top ["IP: 0.0.0.1"]
alert11[Alert 1]
alert22[Alert 2]
end
subgraph bottom ["IP: 0.0.0.2"]
alert23[Alert 3]
end
groupingBy --> top
groupingBy --> bottom
alert1[Alert 1]
alert2[Alert 2]
alert3[Alert 3]
groupingBy[Grouping by IP]
```
```mermaid
flowchart LR
alert1 --> groupingBy
alert2 --> groupingBy
alert3 --> groupingBy
subgraph top ["IP: 0.0.0.1 AND host.name: A"]
alert11[Alert 1]
alert22[Alert 2]
end
subgraph bottom ["IP: 0.0.0.2 AND host.name: B"]
alert23[Alert 3]
end
groupingBy --> top
groupingBy --> bottom
alert1[Alert 1]
alert2[Alert 2]
alert3[Alert 3]
groupingBy[Grouping by IP and host name]
```
## Case creation
For each rule and each group produced by the grouping step, a case will be created and the alerts of that group will be attached to the new case. In future executions of the rule, new alerts that belong to the same group will be attached to the same case. If an alert cannot be grouped, because the grouping field does not exist in its data, it will be attached to a case that represents the `unknown` value.
To support this, the case action constructs a deterministic deduplication ID which will be set as the case ID. The ID can be constructed on each execution of the case action without the need to persist it and can correctly map alerts of the same group to the case that represents that group. A deduplication ID has two main advantages:
1. The case action can determine, without persisting the case ID, if a case exists for a specific rule and a specific group.
2. If two Kibana nodes or two executions of the same rule try to create the case with the same ID only one node or execution will succeed. See the Race Conditions section for more details.
The deduplication ID will be constructed as
`sha256(<rule_id>:<space_id>:<owner>:<grouping_definition>:<counter>)`
where
`<rule_id>`: The ID of the rule. Including it in the deduplication ID ensures all cases created are at least rule-specific.
`<space_id>`: The space ID of the rule. Space ID is a required field.
`<owner>`: The owner of the Case. This will be set to the application from which the rule was created from. Owner is a required field.
`<grouping_definition>`: The grouping field and the grouping value. It can be optional to support attaching all alerts of a rule to the same case.
`<counter>`: The total number of cases with the same rule ID and same group definition. See Time Window for more details.
It is not possible for the `<rule_id>` and the `<grouping_definition>` to be undefined at the same time. If the `<grouping_definition>` is not defined by the user then the rule `<rule_id>` will be set automatically by the case action.
Examples of possible deduplication IDs:
`sha256(test_rule_id:default:securitySolution:{"host.ip":"0.0.0.1"}:0)`
`sha256(test_rule_2_id:my_space:observability:{"host.ip":"0.0.0.1","host.name":"A"}:1)`
`sha256(test_rule_2_id:default:securitySolution::0)`
`sha256(:default:host.ip=0.0.0.1:observability:0)`
The case action sorts deterministically the group definition by the field key to avoid having different IDs for the same group definition. For example, `{"host.ip":"0.0.0.1","host.name":"A"}` and `{"host.name":"A","host.ip":"0.0.0.1"}` will produce the same deduplication ID.
```mermaid
flowchart LR
alert1 --> groupingBy
alert2 --> groupingBy
alert3 --> groupingBy
alert4 --> groupingBy
subgraph top ["IP: 0.0.0.1"]
alert11[Alert 1]
alert22[Alert 2]
end
subgraph bottom ["IP: 0.0.0.2"]
alert23[Alert 3]
end
subgraph no_grouping ["IP: unknown"]
alert34[Alert 4]
end
groupingBy --> top
groupingBy --> bottom
groupingBy --> no_grouping
top --> caseOne
bottom --> caseTwo
no_grouping --> caseThree
alert1[Alert 1]
alert2[Alert 2]
alert3[Alert 3]
alert4[Alert 4]
groupingBy[Grouping by IP]
caseOne[Case 1]
caseTwo[Case 2]
caseThree[Case 3]
```
## Time window
Users are able to define a time window. The case action will attach alerts generated within the time window to the same case. For example, if the time window is set to 7 days all the alerts generated within the next 7 days will be assigned to the same case, and on the 8th day, a new case will be created. The time window is defaulted to seven days.
The new case is a continuation of the previous case. It still represents the case of the specific rule and the specific group (RGD).
To be able to support time windows we need the following:
1. Unique deduplication ID for cases with the same rule ID and the same grouping definition (RGD).
2. Be able to detect if the time window has elapsed.
By adding the counter in the deduplication ID we guarantee that the ID will be unique for the same rule ID and group definition. To be able to increase the counter when needed and to detect if the time window has elapsed for the specific RGDP an Oracle [2] is used. The Oracle keeps a map of the current counter and the last date it got updated by the case action for all RGDP. The Oracle satisfies the following properties:
1. For a valid RGDP, it will return the latest case counter.
2. If two executions of the same rule try to increase the counter at the same time only one execution will succeed.
3. If two Kibana nodes try to increase the counter at the same time only one node will succeed.
An entry in the map of the Oracle looks like this:
| Key (saved object ID) | Value (saved object attributes) |
| :----------------------------------------------------------- | :---------------------------------------------------------- |
| `sha256(<rule_id>:<space_id>:<owner>:<grouping_definition>)` | `{ counter, createdAt, updatedAt, cases, rules, grouping }` |
The `cases`, `rules`, and `grouping` are needed in case we need to provide correlation statistics between cases and rules. The map is persisted in a dedicated saved object called `cases-oracle`. The SavedObject client is used to create and update the records of the map.
```mermaid
flowchart RL
caseThree --> caseTwo
caseTwo --> caseOne
caseOne[Case 1]
caseTwo[Case 2]
caseThree[Case 3]
```
```mermaid
flowchart TB
subgraph top ["Rule ID: test-rule. Grouping definition: host.name=A"]
direction BT
subgraph first ["7 days / counter 1"]
direction BT
re11 --> caseOne
re12 --> caseOne
re13 --> caseOne
re11[Rule execution]
re12[Rule execution]
re13[Rule execution]
end
subgraph second ["7 days / counter 2"]
direction BT
re21 --> caseTwo
re21[Rule execution]
end
subgraph three ["7 days / counter 3"]
direction BT
re31 --> caseThree
re32 --> caseThree
re31[Rule execution]
re32[Rule execution]
end
caseOne[Case 1]
caseTwo[Case 2]
caseThree[Case 3]
end
```
## Usage of the Oracle
The case action deterministically calculates the key of the Oracle mapping based on the rule ID and the GD. Then it executes the following steps:
1. Get the record by key. The record contains the latest counter and the latest date the counter got updated for the specific RGDP. If the record does not exist it will create the record, set the counter to one, and set the `updatedAt` and `createdAt` to the current timestamp.
2. Check if `updatedAt` + `timeWindow` < `now`.
1. If the expression results to true (i.e. the current time is still within the time window), it will calculate the case ID as described in the Case creation section, and attach the alerts to the existing case.
2. If the expression results to false (i.e. the current time is not within the time window), it will increase the counter, calculate the case ID as described in the Case creation section using the increased counter, create the case, and attach the alerts to the new case.
If a version conflict occurs in any of the steps, the execution will be rescheduled to run again. See the Race Conditions section for more details.
```mermaid
flowchart TB
startState --> generateMappingKey
generateMappingKey --> getRecord
getRecord --> recordExists
recordExists -->|Yes| timeWindowElapsed
recordExists -->|No| createRecord
createRecord --> endState
timeWindowElapsed -->|Yes| updateCounter
timeWindowElapsed -->|No| endState
updateCounter --> endState
startState((Start))
generateMappingKey[Generate oracle mapping key]
getRecord[Get oracle record by key]
createRecord[Create record and set the counter to one]
recordExists{Does the record exist?}
timeWindowElapsed{Does the time window elapses?}
updateCounter[Update counter]
endState((End))
```
## Race conditions
There are two important operations within the case action: incrementing the counter in the Oracle and the creation of the case. It is possible for these operations to be executed at the same time by two Kibana nodes or by two executions of the same rule at the same time. The first scenario may happen if two rules with the same grouping definition (GD) try to either increase the counter or create the case at the same time. The second scenario may happen if the rule is scheduled to run very often. It is possible for the next run of the rule to happen before the first execution finishes. Although less likely, it may be possible for two different executions of the same rule to try to update the counter or create a new case at the same time especially if the execution time is much bigger than the interval time of the rule.
### Updating the counter
The optimistic concurrency control mechanism [1] of Elasticsearch guarantees that changes are applied in the correct order; an older version of a document does not overwrite a newer version. The case action leverages this property to ensure that the counter stored in the mapping of the Oracle is updated correctly in the scenario of race conditions. Specifically, the case action first gets the counter for the current RGDP. Along with the attributes, the version of the saved object is returned. In the case of the update, the case action provides the version and tries to update the counter. If in the meantime the counter got updated the version of the saved object will be different. As the provided version of the saved object is different from the current one, a conflict error will be thrown.
```mermaid
flowchart RL
subgraph one [Kibana node]
ruleA[Rule]
end
subgraph two [Kibana node]
ruleB[Rule]
end
one -->|Get counter|oracle --> |Counter: 1|one -->|Update counter to 2|oracle
two -->|Get counter|oracle --> |Counter: 1|two --x|"Update counter to 2 (failed)"|oracle
oracle[Oracle]
```
```mermaid
flowchart LR
subgraph one [Kibana node]
executionA[Execution A]
executionB[Execution B]
end
executionA -->|Get counter|oracle --> |Counter: 1|executionA -->|Update counter to 2|oracle
executionB -->|Get counter|oracle --> |Counter: 1|executionB --x|"Update counter to 2 (failed)"|oracle
oracle[Oracle]
```
### Creating the case
The ID of the case is computed deterministically based on the rule ID, the group definition, and the counter. If for some reason, two executions of the case action compute the same case ID and try to create the case at the same time one of the two executions will succeed and only one case will be created. This is a guarantee provided by Elasticsearch [4].
```mermaid
flowchart LR
subgraph one [Kibana node]
ruleA[Rule]
end
subgraph two [Kibana node]
ruleB[Rule]
end
one -->|Create case with ID 1|case
two --x|"Create case with ID 1 (failed)"|case
case[Case 1]
```
```mermaid
flowchart LR
subgraph one [Kibana node]
executionA[Execution A]
executionB[Execution B]
end
executionA -->|Create case with ID 1|case
executionB --x|"Create case with ID 1 (failed)"|case
case[Case 1]
```
### Retries
To avoid losing alerts in case of conflicts, the failed executions will be retried again. The case action applies a capped exponential back-off mechanism with some randomness for retries [5, 6]. The maximum number of retries is set to ten and the duration between retries is short to avoid timeouts. As the case action is idempotent it is safe to retry the same action multiple times. The retry policy applies to all conflict failures. Specifically:
| Outcome | Reason | Flow of the retried execution |
| :------------------------------------------------- | :------------------------------------------- | :----------------------------------------------------------------------------------------------------------- |
| Failure due to conflicts when updating the counter | The counter got updated by another execution | No need to increase the counter as it was increased by the previous execution. The new counter will be used. |
| Failure due to conflicts when creating a case | The case got created by another execution | No need to create a new case. Alerts will be attached to the case created by the previous execution. |
If the case action exhausts all retries then the execution is rescheduled to be executed again at some point in the future by the task manager. This is a feature supported by the alerting framework and will be used by the case action. The maximum number of retries, using the alerting framework, is set to three.
By having a retry policy on conflicts, the possibility of encountering the same race conditions is very low [5, 6]. In the very unlikely scenario [5, 6] where all retries fail, the alerts will be not attached to any case.
## RBAC
Cases are used in the Security Solution, Observability, and Stack. A user having access only to one solution should not be able to create or view cases of another solution. To achieve that, Cases developed its own RBAC. A field called `owner`, in the case SavedObject, indicates the solution to which the case belongs. For example, a case with owner `securitySolution` belongs to Security Solution. A user with no access to Security Solution cannot access cases with owner `securitySolution`. The case action uses the cases client to create and update cases and to attach alerts to cases. The case client performs RBAC checks on these operations.
The case action gets the owner as a configuration parameter. The UI sets the owner based on the application the user is creating the rule from. This means that if a user creates a rule from within the Security Solution the case will be created in Security Solution. Same for the Observability.
## Circuit breakers & Optimizations
The total number of cases that can be created or updated and the total number of records in the mapping of the Oracle is associated with the total number of grouping fields and the total number of unique values per grouping field. For example:
| Grouping fields | Total unique values | Total unique values |
| :------------------------------ | :-------------------------------------------------- | :------------------ |
| host.name | 2 | 2 |
| host.name & dest.ip | 2 for host.name and 3 for dest.ip | 6 |
| host.name & dest.ip & file.hash | 2 for host.name, 3 for dest.ip, and 2 for file.hash | 12 |
For `n` fields, the total number of cases that can be created is `|S1|*|S2|...|Sn|` where `Sn` is the set containing all unique values of the `n` grouping field [8]. For example, if there are 5 grouping fields with 10 unique values on each one then the total number of cases that will be created are `10^5 = 100.000`, a very high number. To avoid creating too many cases the number of total fields a user can define will be capped to one. With this limit the total number of cases that can be created is `|S1|`. Still, it can lead to a high number of cases if the unique values per grouping field are a lot. To mitigate this the case action a) uses a bulk create case API to create multiple cases at the same time and b) puts a limit on the number of total cases that can be created on an execution. The limit is set to 10 cases and can be configured by users. If more than 10 cases need to be created then the case action will create one case and attach all the alerts to that case. As the size of the mapping of the Oracle is related to the number of grouping fields and grouping values the same mitigations will apply also to it.
## Missing data
It is possible for users to delete an auto-created case or to import partial data. The case action should handle these scenarios gracefully. The following table and the following diagrams show all possible scenarios when either the case is auto-created or the counter is missing or invalid.
| Counter | Case | Reason | Resolution action |
| :------ | :-------- | :------------------------------------------ | :------------------------------------------------------------------ |
| Invalid | Found | Oracle is not imported or deleted | Set the counter to zero. Start over. Attach the alerts to the case. |
| Valid | Not found | The case is deleted or not imported | Create the case using the valid counter. |
| Valid | Found | System is functioning as expected | Continue the flow as expected. |
| Invalid | Not found | Oracle and case are not imported or deleted | Set the counter to zero and start over. |
```mermaid
flowchart BT
caseAction<-->|Get counter|oracle
caseAction-->|"Attach alerts \nCounter: 1"|caseOne
caseAction-->|"Create case \nCounter: 2"|caseTwo
caseAction-->|"Attach alerts \nCounter: 3"|caseThree
caseOne["Case 1 \n(exists)"]
caseTwo["Case 2 \n(does not exist)"]
caseThree["Case 3 \n(exists)"]
caseAction[Case action]
oracle["Oracle \nCounter: 1"]
```
```mermaid
flowchart BT
caseAction<-->|Get counter|oracle
caseAction-->|"Create case \nCounter: 1"|caseOne
caseAction-->|"Create case \nCounter: 2"|caseTwo
caseAction-->|"Create case \nCounter: 3"|caseThree
caseOne["Case 1 \n(does not exist)"]
caseTwo["Case 2 \n(does not exist)"]
caseThree["Case 3 \n(does not exist)"]
caseAction[Case action]
oracle["Oracle \n(does not exist)"]
```
```mermaid
flowchart BT
caseAction<-->|Get counter|oracle
caseAction-->|"Attach alerts \nCounter: 1"|caseOne
caseAction-->|"Create case \nCounter: 2"|caseTwo
caseAction-->|"Attach alerts \nCounter: 3"|caseThree
caseOne["Case 1 \n(exist)"]
caseTwo["Case 2 \n(does not exist)"]
caseThree["Case 3 \n(exist)"]
caseAction[Case action]
oracle["Oracle \n(does not exist)"]
```
## Case action as a finite state machine
The case action can be modeled as a finite state machine. The following state machine shows all the possible states and transitions of the case action.
```mermaid
stateDiagram-v2
%% oracle ID: getting counter
[*]-->oracleIdGen: Generate Oracle ID
oracleIdGen-->gettingCounter: Get counter
gettingCounter-->counterX: Success
gettingCounter-->conflictError: Confict Error
gettingCounter-->counterNoFound: No found
counterNoFound-->setCounterToOne: Set counter to 1
setCounterToOne-->counterIsOne: Success
setCounterToOne-->conflictError: Confict Error
%% handle time window
counterX-->calculateTimeWindow: Calculate time window
counterIsOne-->calculateTimeWindow: Calculate time window
calculateTimeWindow-->timeWindowElapsed: Time window elapsed
calculateTimeWindow-->timeWindowNotElapsed: Time window not elapsed
timeWindowElapsed-->incresingCounter: Increase counter by one
incresingCounter-->counterIncreased: Success
incresingCounter-->conflictError: Confict Error
%% case ID
counterIncreased-->caseIdGen: Generate case ID
timeWindowNotElapsed-->caseIdGen: Generate case ID
caseIdGen-->gettingCase: Get case
gettingCase-->caseFound: Success
gettingCase-->caseNoFound: No found
gettingCase-->conflictError: Confict Error
caseNoFound-->createCase: Create new case
createCase-->caseCreated: Success
createCase-->conflictError: Confict Error
%% closed cases
caseFound-->getCaseStatus: Get case status
getCaseStatus-->closedCase: Case closed
getCaseStatus-->notClosedCase: Case not closed
closedCase-->reopenCase: Reopen case
reopenCase-->caseReopened: Success
reopenCase-->conflictError: Confict Error
closedCase-->createCase: Create new case
%% alerts
caseReopened-->calculateAlertsOnCase: Calculate total alerts on case
notClosedCase-->calculateAlertsOnCase: Calculate total alerts on case
calculateAlertsOnCase-->caseAlertsOnLimit: Alerts on case ≥ 1K
calculateAlertsOnCase-->caseAlertsNotLimit: Alerts on case < 1K
caseAlertsNotLimit-->attachAlertsToCase: Attach alerts to case
caseCreated-->attachAlertsToCase: Attach alerts to case
attachAlertsToCase-->alertsAttachedToCase: Success
attachAlertsToCase-->conflictError: Confict Error
%% errors and end states
conflictError-->[*]
alertsAttachedToCase -->[*]
caseAlertsOnLimit -->[*]
%% counters
oracleIdGen: Oracle ID generated
gettingCounter: Getting counter
counterX: Counter is X
counterNoFound: Counter no found
setCounterToOne: Setting counter to 1
counterIsOne: Counter is one
incresingCounter: Increasing counter by one
counterIncreased: Counter increased by one
%% cases
caseIdGen: Case ID generated
gettingCase: Getting case
caseFound: Case found
caseNoFound: Case no found
createCase: Creating case
caseCreated: Case created
getCaseStatus: Getting case status
closedCase: Case is closed
notClosedCase: Case is not closed
reopenCase: Reopening case
caseReopened: Case reopened
%% time window
calculateTimeWindow: Calculating time window
timeWindowElapsed: Time window elapsed
timeWindowNotElapsed: Time window not elapsed
%% alerts
calculateAlertsOnCase: Calculating total alerts on case
caseAlertsOnLimit: Alerts on case ≥ 1K
caseAlertsNotLimit: Alerts on case < 1K
attachAlertsToCase: Attaching alerts to case
alertsAttachedToCase: Alerts attached to case
%% errors
conflictError: Confict Error
```
## Error handling
If 409 (Conflict), 429 (Too Many Requests), or 503 (ES Unavailable) occurs the case action will retry the execution as described on the Retries section. For all other errors, the case action will be rescheduled by the task manager. The retry mechanism of the case action and the alerting framework will eliminate most of the transient errors [5, 6]. If after three attempts the execution still fails, the case action will not be rescheduled again, the error will be logged to the event log and no cases will be created.
## References
[1] Elastic. 2023. “Optimistic Concurrency Control”. [URL](https://www.elastic.co/guide/en/elasticsearch/guide/master/optimistic-concurrency-control.html).
[2] Wikipedia. 2023. “Oracle machine”. [URL](https://en.wikipedia.org/wiki/Oracle_machine).
[3] Elastic. 2023. “Update Cases API”. [URL](https://www.elastic.co/guide/en/kibana/master/cases-api-update.html).
[4] Elastic. 2023. “Index API”. [URL](https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-index_.html#operation-type).
[5] Amazon AWS. 2023. “Exponential Backoff And Jitter”. [URL](https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/).
[6] Amazon AWS. 2023. “Failures Happen”. [URL](https://aws.amazon.com/builders-library/timeouts-retries-and-backoff-with-jitter/?did=ba_card&trk=ba_card).
[7] Kibana telemetry. 2023. “Alerting rule metrics”.
[8] Wikipedia. 2023. “Rule of product”. [URL](https://en.wikipedia.org/wiki/Rule_of_product).
[9] Github 2023. “json-stable-stringify”. [URL](https://github.com/ljharb/json-stable-stringify).

View file

@ -0,0 +1,288 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import Boom from '@hapi/boom';
import { actionsMock } from '@kbn/actions-plugin/server/mocks';
import { actionsConfigMock } from '@kbn/actions-plugin/server/actions_config.mock';
import { loggingSystemMock } from '@kbn/core-logging-server-mocks';
import { CasesConnector } from './cases_connector';
import { CasesConnectorExecutor } from './cases_connector_executor';
import { CASES_CONNECTOR_ID } from '../../../common/constants';
import { CasesOracleService } from './cases_oracle_service';
import { CasesService } from './cases_service';
import { CasesConnectorError } from './cases_connector_error';
import { CaseError } from '../../common/error';
import { fullJitterBackoffFactory } from './full_jitter_backoff';
import { CoreKibanaRequest } from '@kbn/core/server';
jest.mock('./cases_connector_executor');
jest.mock('./full_jitter_backoff');
const CasesConnectorExecutorMock = CasesConnectorExecutor as jest.Mock;
const fullJitterBackoffFactoryMock = fullJitterBackoffFactory as jest.Mock;
describe('CasesConnector', () => {
const services = actionsMock.createServices();
const logger = loggingSystemMock.createLogger();
const kibanaRequest = CoreKibanaRequest.from({ path: '/', headers: {} });
const groupingBy = ['host.name', 'dest.ip'];
const rule = {
id: 'rule-test-id',
name: 'Test rule',
tags: ['rule', 'test'],
ruleUrl: 'https://example.com/rules/rule-test-id',
};
const owner = 'cases';
const timeWindow = '7d';
const reopenClosedCases = false;
const maximumCasesToOpen = 5;
const mockExecute = jest.fn();
const getCasesClient = jest.fn().mockResolvedValue({ foo: 'bar' });
const getSpaceId = jest.fn().mockReturnValue('default');
const getUnsecuredSavedObjectsClient = jest.fn();
// 1ms delay before retrying
const nextBackOff = jest.fn().mockReturnValue(1);
const backOffFactory = {
create: () => ({ nextBackOff }),
};
const casesParams = { getCasesClient, getSpaceId, getUnsecuredSavedObjectsClient };
const connectorParams = {
configurationUtilities: actionsConfigMock.create(),
config: {},
secrets: {},
connector: { id: '1', type: CASES_CONNECTOR_ID },
logger,
services,
request: kibanaRequest,
};
let connector: CasesConnector;
beforeEach(() => {
jest.clearAllMocks();
mockExecute.mockResolvedValue({});
CasesConnectorExecutorMock.mockImplementation(() => {
return {
execute: mockExecute,
};
});
fullJitterBackoffFactoryMock.mockReturnValue(backOffFactory);
connector = new CasesConnector({
casesParams,
connectorParams,
});
});
it('creates the CasesConnectorExecutor correctly', async () => {
await connector.run({
alerts: [{ _id: 'alert-id-0', _index: 'alert-index-0' }],
groupingBy,
owner,
rule,
timeWindow,
reopenClosedCases,
maximumCasesToOpen,
});
expect(CasesConnectorExecutorMock).toBeCalledWith({
logger,
casesClient: { foo: 'bar' },
casesOracleService: expect.any(CasesOracleService),
casesService: expect.any(CasesService),
spaceId: 'default',
});
});
it('executes the CasesConnectorExecutor correctly', async () => {
await connector.run({
alerts: [{ _id: 'alert-id-0', _index: 'alert-index-0' }],
groupingBy,
owner,
rule,
timeWindow,
reopenClosedCases,
maximumCasesToOpen,
});
expect(mockExecute).toBeCalledWith({
alerts: [{ _id: 'alert-id-0', _index: 'alert-index-0' }],
groupingBy,
owner,
rule,
timeWindow,
reopenClosedCases,
maximumCasesToOpen,
});
});
it('creates the cases client correctly', async () => {
await connector.run({
alerts: [{ _id: 'alert-id-0', _index: 'alert-index-0' }],
groupingBy,
owner,
rule,
timeWindow,
reopenClosedCases,
maximumCasesToOpen,
});
expect(getCasesClient).toBeCalled();
});
it('throws the same error if the executor throws a CasesConnectorError error', async () => {
mockExecute.mockRejectedValue(new CasesConnectorError('Bad request', 400));
await expect(() =>
connector.run({
alerts: [{ _id: 'alert-id-0', _index: 'alert-index-0' }],
groupingBy,
owner,
rule,
timeWindow,
reopenClosedCases,
maximumCasesToOpen,
})
).rejects.toThrowErrorMatchingInlineSnapshot(`"Bad request"`);
expect(logger.error.mock.calls[0][0]).toBe(
'[CasesConnector][run] Execution of case connector failed. Message: Bad request. Status code: 400'
);
});
it('throws a CasesConnectorError when the executor throws an CaseError error', async () => {
mockExecute.mockRejectedValue(new CaseError('Forbidden'));
await expect(() =>
connector.run({
alerts: [{ _id: 'alert-id-0', _index: 'alert-index-0' }],
groupingBy,
owner,
rule,
timeWindow,
reopenClosedCases,
maximumCasesToOpen,
})
).rejects.toThrowErrorMatchingInlineSnapshot(`"Forbidden"`);
expect(logger.error.mock.calls[0][0]).toBe(
'[CasesConnector][run] Execution of case connector failed. Message: Forbidden. Status code: 500'
);
});
it('throws a CasesConnectorError when the executor throws an Error', async () => {
mockExecute.mockRejectedValue(new Error('Server error'));
await expect(() =>
connector.run({
alerts: [{ _id: 'alert-id-0', _index: 'alert-index-0' }],
groupingBy,
owner,
rule,
timeWindow,
reopenClosedCases,
maximumCasesToOpen,
})
).rejects.toThrowErrorMatchingInlineSnapshot(`"Server error"`);
expect(logger.error.mock.calls[0][0]).toBe(
'[CasesConnector][run] Execution of case connector failed. Message: Server error. Status code: 500'
);
});
it('throws a CasesConnectorError when the executor throws a Boom error', async () => {
mockExecute.mockRejectedValue(
new Boom.Boom('Server error', { statusCode: 403, message: 'my error message' })
);
await expect(() =>
connector.run({
alerts: [{ _id: 'alert-id-0', _index: 'alert-index-0' }],
groupingBy,
owner,
rule,
timeWindow,
reopenClosedCases,
maximumCasesToOpen,
})
).rejects.toThrowErrorMatchingInlineSnapshot(`"Forbidden: Server error"`);
expect(logger.error.mock.calls[0][0]).toBe(
'[CasesConnector][run] Execution of case connector failed. Message: Forbidden: Server error. Status code: 403'
);
});
it('retries correctly', async () => {
mockExecute
.mockRejectedValueOnce(new CasesConnectorError('Conflict error', 409))
.mockRejectedValueOnce(new CasesConnectorError('ES Unavailable', 503))
.mockResolvedValue({});
await connector.run({
alerts: [{ _id: 'alert-id-0', _index: 'alert-index-0' }],
groupingBy,
owner,
rule,
timeWindow,
reopenClosedCases,
maximumCasesToOpen,
});
expect(nextBackOff).toBeCalledTimes(2);
expect(mockExecute).toBeCalledTimes(3);
});
it('throws if the kibana request is not defined', async () => {
connector = new CasesConnector({
casesParams,
connectorParams: { ...connectorParams, request: undefined },
});
await expect(() =>
connector.run({
alerts: [{ _id: 'alert-id-0', _index: 'alert-index-0' }],
groupingBy,
owner,
rule,
timeWindow,
reopenClosedCases,
maximumCasesToOpen,
})
).rejects.toThrowErrorMatchingInlineSnapshot(`"Kibana request is not defined"`);
expect(logger.error.mock.calls[0][0]).toBe(
'[CasesConnector][run] Execution of case connector failed. Message: Kibana request is not defined. Status code: 400'
);
expect(nextBackOff).toBeCalledTimes(0);
expect(mockExecute).toBeCalledTimes(0);
});
it('does not execute with no alerts', async () => {
await connector.run({
alerts: [],
groupingBy,
owner,
rule,
timeWindow,
reopenClosedCases,
maximumCasesToOpen,
});
expect(getCasesClient).not.toBeCalled();
expect(CasesConnectorExecutorMock).not.toBeCalled();
expect(mockExecute).not.toBeCalled();
expect(nextBackOff).not.toBeCalled();
});
});

View file

@ -0,0 +1,210 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import Boom from '@hapi/boom';
import type { ServiceParams } from '@kbn/actions-plugin/server';
import { SubActionConnector } from '@kbn/actions-plugin/server';
import type { KibanaRequest } from '@kbn/core-http-server';
import type { SavedObjectsClientContract } from '@kbn/core/server';
import { SAVED_OBJECT_TYPES } from '../../../common';
import type { CasesConnectorConfig, CasesConnectorRunParams, CasesConnectorSecrets } from './types';
import { CasesConnectorRunParamsSchema } from './schema';
import { CasesOracleService } from './cases_oracle_service';
import { CasesService } from './cases_service';
import type { CasesClient } from '../../client';
import {
CasesConnectorError,
isCasesClientError,
isCasesConnectorError,
} from './cases_connector_error';
import { CasesConnectorExecutor } from './cases_connector_executor';
import { CaseConnectorRetryService } from './retry_service';
import { fullJitterBackoffFactory } from './full_jitter_backoff';
import { CASE_RULES_SAVED_OBJECT, CASES_CONNECTOR_SUB_ACTION } from '../../../common/constants';
interface CasesConnectorParams {
connectorParams: ServiceParams<CasesConnectorConfig, CasesConnectorSecrets>;
casesParams: {
getCasesClient: (request: KibanaRequest) => Promise<CasesClient>;
getSpaceId: (request?: KibanaRequest) => string;
getUnsecuredSavedObjectsClient: (
request: KibanaRequest,
savedObjectTypes: string[]
) => Promise<SavedObjectsClientContract>;
};
}
export class CasesConnector extends SubActionConnector<
CasesConnectorConfig,
CasesConnectorSecrets
> {
private readonly casesService: CasesService;
private readonly retryService: CaseConnectorRetryService;
private readonly casesParams: CasesConnectorParams['casesParams'];
constructor({ connectorParams, casesParams }: CasesConnectorParams) {
super(connectorParams);
this.casesService = new CasesService();
/**
* We should wait at least 5ms before retrying and no more that 2sec
*/
const backOffFactory = fullJitterBackoffFactory({ baseDelay: 5, maxBackoffTime: 2000 });
this.retryService = new CaseConnectorRetryService(this.logger, backOffFactory);
this.casesParams = casesParams;
this.registerSubActions();
}
private registerSubActions() {
this.registerSubAction({
name: CASES_CONNECTOR_SUB_ACTION.RUN,
method: 'run',
schema: CasesConnectorRunParamsSchema,
});
}
/**
* Method is not needed for the Case Connector.
* The function throws an error as a reminder to
* implement it if we need it in the future.
*/
protected getResponseErrorMessage(): string {
throw new Error('Method not implemented.');
}
public async run(params: CasesConnectorRunParams) {
if (!this.kibanaRequest) {
const error = new CasesConnectorError('Kibana request is not defined', 400);
this.handleError(error);
}
if (params.alerts.length === 0) {
this.logDebugCurrentState(
'start',
'[CasesConnector][_run] No alerts. Skipping execution.',
params
);
return;
}
await this.retryService.retryWithBackoff(() => this._run(params));
}
private async _run(params: CasesConnectorRunParams) {
try {
/**
* The case connector will throw an error if the Kibana request
* is not define before executing the _run method
*/
const kibanaRequest = this.kibanaRequest as KibanaRequest;
const casesClient = await this.casesParams.getCasesClient(kibanaRequest);
const savedObjectsClient = await this.casesParams.getUnsecuredSavedObjectsClient(
kibanaRequest,
[...SAVED_OBJECT_TYPES, CASE_RULES_SAVED_OBJECT]
);
const spaceId = this.casesParams.getSpaceId(kibanaRequest);
const casesOracleService = new CasesOracleService({
logger: this.logger,
savedObjectsClient,
});
const connectorExecutor = new CasesConnectorExecutor({
logger: this.logger,
casesOracleService,
casesService: this.casesService,
casesClient,
spaceId,
});
this.logDebugCurrentState('start', '[CasesConnector][_run] Executing case connector', params);
await connectorExecutor.execute(params);
this.logDebugCurrentState(
'success',
'[CasesConnector][_run] Execution of case connector succeeded',
params
);
} catch (error) {
this.handleError(error);
} finally {
this.logDebugCurrentState(
'end',
'[CasesConnector][_run] Execution of case connector ended',
params
);
}
}
private handleError(error: Error) {
if (isCasesConnectorError(error)) {
this.logError(error);
throw error;
}
if (isCasesClientError(error)) {
const caseConnectorError = new CasesConnectorError(
error.message,
error.boomify().output.statusCode
);
this.logError(caseConnectorError);
throw caseConnectorError;
}
if (Boom.isBoom(error)) {
const caseConnectorError = new CasesConnectorError(
`${error.output.payload.error}: ${error.output.payload.message}`,
error.output.statusCode
);
this.logError(caseConnectorError);
throw caseConnectorError;
}
const caseConnectorError = new CasesConnectorError(error.message, 500);
this.logError(caseConnectorError);
throw caseConnectorError;
}
private logDebugCurrentState(state: string, message: string, params: CasesConnectorRunParams) {
const alertIds = params.alerts.map(({ _id }) => _id);
this.logger.debug(`[CasesConnector][_run] ${message}`, {
labels: {
ruleId: params.rule.id,
groupingBy: params.groupingBy,
totalAlerts: params.alerts.length,
timeWindow: params.timeWindow,
reopenClosedCases: params.reopenClosedCases,
owner: params.owner,
},
tags: [`cases-connector:${state}`, params.rule.id, ...alertIds],
});
}
private logError(error: CasesConnectorError) {
this.logger.error(
`[CasesConnector][run] Execution of case connector failed. Message: ${error.message}. Status code: ${error.statusCode}`,
{
error: {
stack_trace: error.stack,
code: error.statusCode.toString(),
type: 'CasesConnectorError',
},
}
);
}
}

View file

@ -0,0 +1,24 @@
/*
* 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 { CaseError } from '../../common/error';
export class CasesConnectorError extends Error {
public readonly statusCode: number;
constructor(message: string, statusCode: number) {
super(message);
this.statusCode = statusCode;
}
}
export const isCasesConnectorError = (error: unknown): error is CasesConnectorError =>
error instanceof CasesConnectorError;
export const isCasesClientError = (error: unknown): error is CaseError =>
error instanceof CaseError;

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,543 @@
/*
* 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 { createHash } from 'node:crypto';
import stringify from 'json-stable-stringify';
import { savedObjectsClientMock } from '@kbn/core-saved-objects-api-server-mocks';
import { loggingSystemMock } from '@kbn/core-logging-server-mocks';
import { CasesOracleService } from './cases_oracle_service';
import { CASE_RULES_SAVED_OBJECT } from '../../../common/constants';
import { isEmpty, set } from 'lodash';
describe('CasesOracleService', () => {
const savedObjectsClient = savedObjectsClientMock.create();
const logger = loggingSystemMock.createLogger();
let service: CasesOracleService;
beforeEach(() => {
jest.resetAllMocks();
service = new CasesOracleService({ savedObjectsClient, logger });
});
describe('getRecordId', () => {
it('return the record ID correctly', async () => {
const ruleId = 'test-rule-id';
const spaceId = 'default';
const owner = 'cases';
const grouping = { 'host.ip': '0.0.0.1' };
const payload = `${ruleId}:${spaceId}:${owner}:${stringify(grouping)}`;
const hash = createHash('sha256');
hash.update(payload);
const hex = hash.digest('hex');
expect(service.getRecordId({ ruleId, spaceId, owner, grouping })).toEqual(hex);
});
it('sorts the grouping definition correctly', async () => {
const ruleId = 'test-rule-id';
const spaceId = 'default';
const owner = 'cases';
const grouping = { 'host.ip': '0.0.0.1', 'agent.id': '8a4f500d' };
const sortedGrouping = { 'agent.id': '8a4f500d', 'host.ip': '0.0.0.1' };
const payload = `${ruleId}:${spaceId}:${owner}:${stringify(sortedGrouping)}`;
const hash = createHash('sha256');
hash.update(payload);
const hex = hash.digest('hex');
expect(service.getRecordId({ ruleId, spaceId, owner, grouping })).toEqual(hex);
});
it('return the record ID correctly without grouping', async () => {
const ruleId = 'test-rule-id';
const spaceId = 'default';
const owner = 'cases';
const payload = `${ruleId}:${spaceId}:${owner}`;
const hash = createHash('sha256');
hash.update(payload);
const hex = hash.digest('hex');
expect(service.getRecordId({ ruleId, spaceId, owner })).toEqual(hex);
});
it('return the record ID correctly with empty grouping', async () => {
const ruleId = 'test-rule-id';
const spaceId = 'default';
const owner = 'cases';
const grouping = {};
const payload = `${ruleId}:${spaceId}:${owner}:${stringify(grouping)}`;
const hash = createHash('sha256');
hash.update(payload);
const hex = hash.digest('hex');
expect(service.getRecordId({ ruleId, spaceId, owner, grouping })).toEqual(hex);
});
it('return the record ID correctly without rule', async () => {
const spaceId = 'default';
const owner = 'cases';
const grouping = { 'host.ip': '0.0.0.1' };
const payload = `${spaceId}:${owner}:${stringify(grouping)}`;
const hash = createHash('sha256');
hash.update(payload);
const hex = hash.digest('hex');
expect(service.getRecordId({ spaceId, owner, grouping })).toEqual(hex);
});
it('throws an error when the ruleId and the grouping is missing', async () => {
const spaceId = 'default';
const owner = 'cases';
// @ts-expect-error: ruleId and grouping are omitted for testing
expect(() => service.getRecordId({ spaceId, owner })).toThrowErrorMatchingInlineSnapshot(
`"ruleID or grouping is required"`
);
});
it.each(['ruleId', 'spaceId', 'owner'])(
'return the record ID correctly with empty string for %s',
async (key) => {
const getPayloadValue = (value: string) => (isEmpty(value) ? '' : `${value}:`);
const params = {
ruleId: 'test-rule-id',
spaceId: 'default',
owner: 'cases',
};
const grouping = { 'host.ip': '0.0.0.1' };
set(params, key, '');
const payload = `${getPayloadValue(params.ruleId)}${getPayloadValue(
params.spaceId
)}${getPayloadValue(params.owner)}${stringify(grouping)}`;
const hash = createHash('sha256');
hash.update(payload);
const hex = hash.digest('hex');
expect(service.getRecordId({ ...params, grouping })).toEqual(hex);
}
);
it('constructs a record ID with special characters correctly', async () => {
const ruleId = `{}=:&".'/{}}`;
const spaceId = 'default:';
const owner = 'cases{';
const grouping = { '{:}': `{}=:&".'/{}}` };
const payload = `${ruleId}:${spaceId}:${owner}:${stringify(grouping)}`;
const hash = createHash('sha256');
hash.update(payload);
const hex = hash.digest('hex');
expect(service.getRecordId({ ruleId, spaceId, owner, grouping })).toEqual(hex);
});
});
describe('getRecord', () => {
const rules = [{ id: 'test-rule-id' }];
const grouping = { 'host.ip': '0.0.0.1', 'agent.id': '8a4f500d' };
const oracleSO = {
id: 'so-id',
version: 'so-version',
attributes: {
counter: 1,
rules,
grouping,
createdAt: '2023-10-10T10:23:42.769Z',
updatedAt: '2023-10-10T10:23:42.769Z',
},
type: CASE_RULES_SAVED_OBJECT,
references: [],
};
beforeEach(() => {
savedObjectsClient.get.mockResolvedValue(oracleSO);
});
it('gets a record correctly', async () => {
const record = await service.getRecord('so-id');
expect(record).toEqual({ ...oracleSO.attributes, id: 'so-id', version: 'so-version' });
});
it('calls the savedObjectsClient.get method correctly', async () => {
await service.getRecord('so-id');
expect(savedObjectsClient.get).toHaveBeenCalledWith('cases-rules', 'so-id');
});
});
describe('bulkGetRecord', () => {
const rules = [{ id: 'test-rule-id' }];
const grouping = { 'host.ip': '0.0.0.1', 'agent.id': '8a4f500d' };
const bulkGetSOs = [
{
id: 'so-id',
version: 'so-version',
attributes: {
counter: 1,
rules,
grouping,
createdAt: '2023-10-10T10:23:42.769Z',
updatedAt: '2023-10-10T10:23:42.769Z',
},
type: CASE_RULES_SAVED_OBJECT,
references: [],
},
{
id: 'so-id-2',
type: CASE_RULES_SAVED_OBJECT,
error: {
message: 'Not found',
statusCode: 404,
error: 'Not found',
},
},
];
beforeEach(() => {
// @ts-expect-error: types of the SO client are wrong and they do not accept errors
savedObjectsClient.bulkGet.mockResolvedValue({ saved_objects: bulkGetSOs });
});
it('formats the response correctly', async () => {
const res = await service.bulkGetRecords(['so-id', 'so-id-2']);
expect(res).toEqual([
{ ...bulkGetSOs[0].attributes, id: 'so-id', version: 'so-version' },
{ ...bulkGetSOs[1].error, id: 'so-id-2' },
]);
});
it('calls the savedObjectsClient.bulkGet method correctly', async () => {
await service.bulkGetRecords(['so-id', 'so-id-2']);
expect(savedObjectsClient.bulkGet).toHaveBeenCalledWith([
{ id: 'so-id', type: 'cases-rules' },
{ id: 'so-id-2', type: 'cases-rules' },
]);
});
it('does not call the savedObjectsClient if the input is an empty array', async () => {
await service.bulkGetRecords([]);
expect(savedObjectsClient.bulkGet).not.toHaveBeenCalledWith();
});
});
describe('createRecord', () => {
const rules = [{ id: 'test-rule-id' }];
const grouping = { 'host.ip': '0.0.0.1', 'agent.id': '8a4f500d' };
const oracleSO = {
id: 'so-id',
version: 'so-version',
attributes: {
counter: 1,
rules,
grouping,
createdAt: '2023-10-10T10:23:42.769Z',
updatedAt: '2023-10-10T10:23:42.769Z',
},
type: CASE_RULES_SAVED_OBJECT,
references: [],
};
beforeEach(() => {
savedObjectsClient.create.mockResolvedValue(oracleSO);
});
it('creates a record correctly', async () => {
const record = await service.createRecord('so-id', { rules, grouping });
expect(record).toEqual({ ...oracleSO.attributes, id: 'so-id', version: 'so-version' });
});
it('calls the savedObjectsClient.create method correctly', async () => {
const id = 'so-id';
await service.createRecord(id, { rules, grouping });
expect(savedObjectsClient.create).toHaveBeenCalledWith(
'cases-rules',
{
counter: 1,
createdAt: expect.anything(),
rules,
grouping,
updatedAt: null,
},
{
id,
references: [
{
id: 'test-rule-id',
name: 'associated-alert',
type: 'alert',
},
],
}
);
});
});
describe('bulkCreateRecord', () => {
const rules = [{ id: 'test-rule-id' }];
const grouping = { 'host.ip': '0.0.0.1', 'agent.id': '8a4f500d' };
const bulkCreateSOs = [
{
id: 'so-id',
version: 'so-version',
attributes: {
counter: 1,
rules,
grouping,
createdAt: '2023-10-10T10:23:42.769Z',
updatedAt: '2023-10-10T10:23:42.769Z',
},
type: CASE_RULES_SAVED_OBJECT,
references: [],
},
{
id: 'so-id-2',
type: CASE_RULES_SAVED_OBJECT,
error: {
message: 'Not found',
statusCode: 404,
error: 'Not found',
},
},
];
beforeEach(() => {
// @ts-expect-error: types of the SO client are wrong and they do not accept errors
savedObjectsClient.bulkCreate.mockResolvedValue({ saved_objects: bulkCreateSOs });
});
it('formats the response correctly', async () => {
const res = await service.bulkCreateRecord([
{ recordId: 'so-id', payload: { rules, grouping } },
{ recordId: 'so-id-2', payload: { rules, grouping } },
]);
expect(res).toEqual([
{ ...bulkCreateSOs[0].attributes, id: 'so-id', version: 'so-version' },
{ ...bulkCreateSOs[1].error, id: 'so-id-2' },
]);
});
it('calls the bulkCreate correctly', async () => {
await service.bulkCreateRecord([
{ recordId: 'so-id', payload: { rules, grouping } },
{ recordId: 'so-id-2', payload: { rules, grouping } },
]);
expect(savedObjectsClient.bulkCreate).toHaveBeenCalledWith([
{
attributes: {
rules,
grouping,
counter: 1,
createdAt: expect.anything(),
updatedAt: null,
},
id: 'so-id',
type: 'cases-rules',
references: [
{
id: 'test-rule-id',
name: 'associated-alert',
type: 'alert',
},
],
},
{
attributes: {
rules,
grouping,
counter: 1,
createdAt: expect.anything(),
updatedAt: null,
},
id: 'so-id-2',
type: 'cases-rules',
references: [
{
id: 'test-rule-id',
name: 'associated-alert',
type: 'alert',
},
],
},
]);
});
it('does not call the savedObjectsClient if the input is an empty array', async () => {
await service.bulkCreateRecord([]);
expect(savedObjectsClient.bulkCreate).not.toHaveBeenCalledWith();
});
});
describe('increaseCounter', () => {
const rules = [{ id: 'test-rule-id' }];
const grouping = { 'host.ip': '0.0.0.1', 'agent.id': '8a4f500d' };
const oracleSO = {
id: 'so-id',
version: 'so-version',
attributes: {
counter: 1,
rules,
grouping,
createdAt: '2023-10-10T10:23:42.769Z',
updatedAt: '2023-10-10T10:23:42.769Z',
},
type: CASE_RULES_SAVED_OBJECT,
references: [],
};
const oracleSOWithIncreasedCounter = {
...oracleSO,
attributes: { ...oracleSO.attributes, counter: 2 },
};
beforeEach(() => {
savedObjectsClient.get.mockResolvedValue(oracleSO);
savedObjectsClient.update.mockResolvedValue(oracleSOWithIncreasedCounter);
});
it('increases the counter correctly', async () => {
const record = await service.increaseCounter('so-id');
expect(record).toEqual({
...oracleSO.attributes,
id: 'so-id',
version: 'so-version',
counter: 2,
});
});
it('calls the savedObjectsClient.update method correctly', async () => {
await service.increaseCounter('so-id');
expect(savedObjectsClient.update).toHaveBeenCalledWith(
'cases-rules',
'so-id',
{
counter: 2,
},
{ version: 'so-version' }
);
});
});
describe('bulkUpdateRecord', () => {
const bulkUpdateSOs = [
{
id: 'so-id',
version: 'so-version',
attributes: {
counter: 1,
rules: [],
grouping: {},
createdAt: '2023-10-10T10:23:42.769Z',
updatedAt: '2023-10-10T10:23:42.769Z',
},
type: CASE_RULES_SAVED_OBJECT,
references: [],
},
{
id: 'so-id-2',
type: CASE_RULES_SAVED_OBJECT,
error: {
message: 'Conflict',
statusCode: 409,
error: 'Conflict',
},
},
];
beforeEach(() => {
// @ts-expect-error: types of the SO client are wrong and they do not accept errors
savedObjectsClient.bulkUpdate.mockResolvedValue({ saved_objects: bulkUpdateSOs });
});
it('formats the response correctly', async () => {
const res = await service.bulkUpdateRecord([
{ recordId: 'so-id', version: 'so-version-1', payload: { counter: 2 } },
{ recordId: 'so-id-2', version: 'so-version-22', payload: { counter: 3 } },
]);
expect(res).toEqual([
{ ...bulkUpdateSOs[0].attributes, id: 'so-id', version: 'so-version' },
{ ...bulkUpdateSOs[1].error, id: 'so-id-2' },
]);
});
it('calls the bulkUpdateRecord correctly', async () => {
await service.bulkUpdateRecord([
{ recordId: 'so-id', version: 'so-version-1', payload: { counter: 2 } },
{ recordId: 'so-id-2', version: 'so-version-2', payload: { counter: 3 } },
]);
expect(savedObjectsClient.bulkUpdate).toHaveBeenCalledWith([
{
attributes: {
counter: 2,
updatedAt: expect.anything(),
},
id: 'so-id',
version: 'so-version-1',
type: 'cases-rules',
},
{
attributes: {
counter: 3,
updatedAt: expect.anything(),
},
id: 'so-id-2',
version: 'so-version-2',
type: 'cases-rules',
},
]);
});
it('does not call the savedObjectsClient if the input is an empty array', async () => {
await service.bulkUpdateRecord([]);
expect(savedObjectsClient.bulkUpdate).not.toHaveBeenCalledWith();
});
});
});

View file

@ -0,0 +1,259 @@
/*
* 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 { RULE_SAVED_OBJECT_TYPE } from '@kbn/alerting-plugin/server';
import type {
Logger,
SavedObject,
SavedObjectReference,
SavedObjectsClientContract,
} from '@kbn/core/server';
import { CASE_RULES_SAVED_OBJECT } from '../../../common/constants';
import { isSODecoratedError, isSOError } from '../../common/error';
import type { SavedObjectsBulkResponseWithErrors } from '../../common/types';
import { INITIAL_ORACLE_RECORD_COUNTER } from './constants';
import { CryptoService } from './crypto_service';
import type {
BulkCreateOracleRecordRequest,
BulkGetOracleRecordsResponse,
BulkUpdateOracleRecordRequest,
OracleKey,
OracleRecord,
OracleRecordAttributes,
OracleRecordCreateRequest,
OracleRecordError,
OracleSOError,
} from './types';
export class CasesOracleService {
private readonly logger: Logger;
private readonly savedObjectsClient: SavedObjectsClientContract;
private cryptoService: CryptoService;
constructor({
logger,
savedObjectsClient,
}: {
logger: Logger;
savedObjectsClient: SavedObjectsClientContract;
}) {
this.logger = logger;
this.savedObjectsClient = savedObjectsClient;
this.cryptoService = new CryptoService();
}
public getRecordId({ ruleId, spaceId, owner, grouping }: OracleKey): string {
if (grouping == null && ruleId == null) {
throw new Error('ruleID or grouping is required');
}
const payload = [
ruleId,
spaceId,
owner,
this.cryptoService.stringifyDeterministically(grouping),
]
.filter(Boolean)
.join(':');
return this.cryptoService.getHash(payload);
}
public async getRecord(recordId: string): Promise<OracleRecord> {
this.logger.debug(`Getting oracle record with ID: ${recordId}`, {
tags: ['cases-oracle-service', 'getRecord', recordId],
});
const oracleRecord = await this.savedObjectsClient.get<OracleRecordAttributes>(
CASE_RULES_SAVED_OBJECT,
recordId
);
return this.getRecordResponse(oracleRecord);
}
public async bulkGetRecords(ids: string[]): Promise<BulkGetOracleRecordsResponse> {
this.logger.debug(`Getting oracle records with IDs: ${ids}`, {
tags: ['cases-oracle-service', 'bulkGetRecords', ...ids],
});
if (ids.length === 0) {
return [];
}
const oracleRecords = (await this.savedObjectsClient.bulkGet<OracleRecordAttributes>(
ids.map((id) => ({ id, type: CASE_RULES_SAVED_OBJECT }))
)) as SavedObjectsBulkResponseWithErrors<OracleRecordAttributes>;
return this.getBulkRecordsResponse(oracleRecords);
}
public async createRecord(
recordId: string,
payload: OracleRecordCreateRequest
): Promise<OracleRecord> {
this.logger.debug(`Creating oracle record with ID: ${recordId}`, {
tags: ['cases-oracle-service', 'createRecord', recordId],
});
const oracleRecord = await this.savedObjectsClient.create<OracleRecordAttributes>(
CASE_RULES_SAVED_OBJECT,
this.getCreateRecordAttributes(payload),
{ id: recordId, references: this.getCreateRecordReferences(payload) }
);
return this.getRecordResponse(oracleRecord);
}
public async bulkCreateRecord(
records: BulkCreateOracleRecordRequest
): Promise<BulkGetOracleRecordsResponse> {
const recordIds = records.map((record) => record.recordId);
this.logger.debug(`Creating oracle record with ID: ${recordIds}`, {
tags: ['cases-oracle-service', 'bulkCreateRecord', ...recordIds],
});
if (records.length === 0) {
return [];
}
const req = records.map((record) => ({
id: record.recordId,
type: CASE_RULES_SAVED_OBJECT,
attributes: this.getCreateRecordAttributes(record.payload),
references: this.getCreateRecordReferences(record.payload),
}));
const oracleRecords = (await this.savedObjectsClient.bulkCreate<OracleRecordAttributes>(
req
)) as SavedObjectsBulkResponseWithErrors<OracleRecordAttributes>;
return this.getBulkRecordsResponse(oracleRecords);
}
public async increaseCounter(recordId: string): Promise<OracleRecord> {
const { id: _, version, ...record } = await this.getRecord(recordId);
const newCounter = record.counter + 1;
this.logger.debug(
`Increasing the counter of oracle record with ID: ${recordId} from ${record.counter} to ${newCounter}`,
{
tags: ['cases-oracle-service', 'increaseCounter', recordId],
}
);
const oracleRecord = await this.savedObjectsClient.update<OracleRecordAttributes>(
CASE_RULES_SAVED_OBJECT,
recordId,
{ counter: newCounter },
{ version }
);
return this.getRecordResponse({
...oracleRecord,
attributes: { ...record, counter: newCounter },
references: oracleRecord.references ?? [],
});
}
public async bulkUpdateRecord(
records: BulkUpdateOracleRecordRequest
): Promise<BulkGetOracleRecordsResponse> {
const recordIds = records.map((record) => record.recordId);
this.logger.debug(`Updating oracle record with ID: ${recordIds}`, {
tags: ['cases-oracle-service', 'bulkUpdateRecord', ...recordIds],
});
if (records.length === 0) {
return [];
}
const req = records.map((record) => ({
id: record.recordId,
type: CASE_RULES_SAVED_OBJECT,
version: record.version,
attributes: { ...record.payload, updatedAt: new Date().toISOString() },
}));
const oracleRecords = (await this.savedObjectsClient.bulkUpdate<OracleRecordAttributes>(
req
)) as SavedObjectsBulkResponseWithErrors<OracleRecordAttributes>;
return this.getBulkRecordsResponse(oracleRecords);
}
private getRecordResponse = (
oracleRecord: SavedObject<OracleRecordAttributes>
): OracleRecord => ({
id: oracleRecord.id,
version: oracleRecord.version ?? '',
counter: oracleRecord.attributes.counter,
grouping: oracleRecord.attributes.grouping,
rules: oracleRecord.attributes.rules,
createdAt: oracleRecord.attributes.createdAt,
updatedAt: oracleRecord.attributes.updatedAt,
});
private getBulkRecordsResponse(
oracleRecords: SavedObjectsBulkResponseWithErrors<OracleRecordAttributes>
): BulkGetOracleRecordsResponse {
return oracleRecords.saved_objects.map((oracleRecord) => {
if (isSOError(oracleRecord)) {
return this.getErrorResponse(oracleRecord.id, oracleRecord.error);
}
return this.getRecordResponse(oracleRecord);
});
}
private getErrorResponse(id: string, error: OracleSOError): OracleRecordError {
if (isSODecoratedError(error)) {
return {
id,
error: error.output.payload.error,
message: error.output.payload.message,
statusCode: error.output.statusCode,
};
}
return {
id,
error: error.error,
message: error.message,
statusCode: error.statusCode,
};
}
private getCreateRecordAttributes({ rules, grouping }: OracleRecordCreateRequest) {
return {
counter: INITIAL_ORACLE_RECORD_COUNTER,
rules,
grouping,
createdAt: new Date().toISOString(),
updatedAt: null,
};
}
private getCreateRecordReferences({
rules,
grouping,
}: OracleRecordCreateRequest): SavedObjectReference[] {
const references = [];
for (const rule of rules) {
references.push({
id: rule.id,
type: RULE_SAVED_OBJECT_TYPE,
name: `associated-${RULE_SAVED_OBJECT_TYPE}`,
});
}
return references;
}
}

View file

@ -0,0 +1,165 @@
/*
* 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 { createHash } from 'node:crypto';
import stringify from 'json-stable-stringify';
import { isEmpty, set } from 'lodash';
import { CasesService } from './cases_service';
describe('CasesService', () => {
let service: CasesService;
beforeEach(() => {
jest.resetAllMocks();
service = new CasesService();
});
describe('getCaseId', () => {
it('return the record ID correctly', async () => {
const ruleId = 'test-rule-id';
const spaceId = 'default';
const owner = 'cases';
const grouping = { 'host.ip': '0.0.0.1' };
const counter = 1;
const payload = `${ruleId}:${spaceId}:${owner}:${stringify(grouping)}:${counter}`;
const hash = createHash('sha256');
hash.update(payload);
const hex = hash.digest('hex');
expect(service.getCaseId({ ruleId, spaceId, owner, grouping, counter })).toEqual(hex);
});
it('sorts the grouping definition correctly', async () => {
const ruleId = 'test-rule-id';
const spaceId = 'default';
const owner = 'cases';
const grouping = { 'host.ip': '0.0.0.1', 'agent.id': '8a4f500d' };
const sortedGrouping = { 'agent.id': '8a4f500d', 'host.ip': '0.0.0.1' };
const counter = 1;
const payload = `${ruleId}:${spaceId}:${owner}:${stringify(sortedGrouping)}:${counter}`;
const hash = createHash('sha256');
hash.update(payload);
const hex = hash.digest('hex');
expect(service.getCaseId({ ruleId, spaceId, owner, grouping, counter })).toEqual(hex);
});
it('return the record ID correctly without grouping', async () => {
const ruleId = 'test-rule-id';
const spaceId = 'default';
const owner = 'cases';
const counter = 1;
const payload = `${ruleId}:${spaceId}:${owner}:${counter}`;
const hash = createHash('sha256');
hash.update(payload);
const hex = hash.digest('hex');
expect(service.getCaseId({ ruleId, spaceId, owner, counter })).toEqual(hex);
});
it('return the record ID correctly with empty grouping', async () => {
const ruleId = 'test-rule-id';
const spaceId = 'default';
const owner = 'cases';
const grouping = {};
const counter = 1;
const payload = `${ruleId}:${spaceId}:${owner}:${stringify(grouping)}:${counter}`;
const hash = createHash('sha256');
hash.update(payload);
const hex = hash.digest('hex');
expect(service.getCaseId({ ruleId, spaceId, owner, grouping, counter })).toEqual(hex);
});
it('return the record ID correctly without rule', async () => {
const spaceId = 'default';
const owner = 'cases';
const grouping = { 'host.ip': '0.0.0.1' };
const counter = 1;
const payload = `${spaceId}:${owner}:${stringify(grouping)}:${counter}`;
const hash = createHash('sha256');
hash.update(payload);
const hex = hash.digest('hex');
expect(service.getCaseId({ spaceId, owner, grouping, counter })).toEqual(hex);
});
it('throws an error when the ruleId and the grouping is missing', async () => {
const spaceId = 'default';
const owner = 'cases';
const counter = 1;
expect(() =>
// @ts-expect-error: ruleId and grouping are omitted for testing
service.getCaseId({ spaceId, owner, counter })
).toThrowErrorMatchingInlineSnapshot(`"ruleID or grouping is required"`);
});
it.each(['ruleId', 'spaceId', 'owner'])(
'return the record ID correctly with empty string for %s',
async (key) => {
const getPayloadValue = (value: string) => (isEmpty(value) ? '' : `${value}:`);
const params = {
ruleId: 'test-rule-id',
spaceId: 'default',
owner: 'cases',
};
const grouping = { 'host.ip': '0.0.0.1' };
const counter = 1;
set(params, key, '');
const payload = `${getPayloadValue(params.ruleId)}${getPayloadValue(
params.spaceId
)}${getPayloadValue(params.owner)}${stringify(grouping)}:${counter}`;
const hash = createHash('sha256');
hash.update(payload);
const hex = hash.digest('hex');
expect(service.getCaseId({ ...params, grouping, counter })).toEqual(hex);
}
);
it('constructs a record ID with special characters correctly', async () => {
const ruleId = `{}=:&".'/{}}`;
const spaceId = 'default';
const owner = 'cases';
const grouping = { '{:}': `{}=:&".'/{}}` };
const counter = 1;
const payload = `${ruleId}:${spaceId}:${owner}:${stringify(grouping)}:${counter}`;
const hash = createHash('sha256');
hash.update(payload);
const hex = hash.digest('hex');
expect(service.getCaseId({ ruleId, spaceId, owner, grouping, counter })).toEqual(hex);
});
});
});

View file

@ -0,0 +1,35 @@
/*
* 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 { CryptoService } from './crypto_service';
import type { CaseIdPayload } from './types';
export class CasesService {
private cryptoService: CryptoService;
constructor() {
this.cryptoService = new CryptoService();
}
public getCaseId({ ruleId, spaceId, owner, grouping, counter }: CaseIdPayload): string {
if (grouping == null && ruleId == null) {
throw new Error('ruleID or grouping is required');
}
const payload = [
ruleId,
spaceId,
owner,
this.cryptoService.stringifyDeterministically(grouping),
counter,
]
.filter(Boolean)
.join(':');
return this.cryptoService.getHash(payload);
}
}

View file

@ -0,0 +1,18 @@
/*
* 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 { CustomFieldTypes } from '../../../common/types/domain';
export const MAX_CONCURRENT_ES_REQUEST = 5;
export const MAX_OPEN_CASES = 10;
export const DEFAULT_MAX_OPEN_CASES = 5;
export const INITIAL_ORACLE_RECORD_COUNTER = 1;
export const VALUES_FOR_CUSTOM_FIELDS_MISSING_DEFAULTS: Record<CustomFieldTypes, unknown> = {
[CustomFieldTypes.TEXT]: 'N/A',
[CustomFieldTypes.TOGGLE]: false,
};

View file

@ -0,0 +1,61 @@
/*
* 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 { createHash } from 'node:crypto';
import { CryptoService } from './crypto_service';
describe('CryptoService', () => {
let service: CryptoService;
beforeEach(() => {
jest.resetAllMocks();
service = new CryptoService();
});
describe('getHash', () => {
it('returns the sha256 of a payload correctly', async () => {
const payload = 'my payload';
const hash = createHash('sha256');
hash.update(payload);
const hex = hash.digest('hex');
expect(service.getHash(payload)).toEqual(hex);
});
it('creates a new instance of the hash function on each call', async () => {
const payload = 'my payload';
const hash = createHash('sha256');
hash.update(payload);
const hex = hash.digest('hex');
expect(service.getHash(payload)).toEqual(hex);
expect(service.getHash(payload)).toEqual(hex);
});
});
describe('stringifyDeterministically', () => {
it('deterministically stringifies an object', async () => {
expect(
service.stringifyDeterministically({ 'host.ip': '0.0.0.1', 'agent.id': '8a4f500d' })
).toEqual('{"agent.id":"8a4f500d","host.ip":"0.0.0.1"}');
});
it('returns null if the object is not defined', async () => {
expect(service.stringifyDeterministically()).toEqual(null);
});
it('handles special characters correctly', async () => {
expect(service.stringifyDeterministically({ [`{}=:&".'/{}}`]: `{}=:&".'{}}` })).toEqual(
`{\"{}=:&\\\".'/{}}\":\"{}=:&\\\".'{}}\"}`
);
});
});
});

View file

@ -0,0 +1,26 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { createHash } from 'node:crypto';
import stringify from 'json-stable-stringify';
export class CryptoService {
public getHash(payload: string): string {
const hash = createHash('sha256');
hash.update(payload);
return hash.digest('hex');
}
public stringifyDeterministically(obj?: Record<string, unknown>): string | null {
if (obj == null) {
return null;
}
return stringify(obj);
}
}

View file

@ -0,0 +1,69 @@
/*
* 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 { fullJitterBackoffFactory } from './full_jitter_backoff';
describe('FullJitterBackoff', () => {
it('throws if the baseDelay is negative', async () => {
expect(() =>
fullJitterBackoffFactory({ baseDelay: -1, maxBackoffTime: 2000 }).create()
).toThrowErrorMatchingInlineSnapshot(`"baseDelay must not be negative"`);
});
it('throws if the maxBackoffTime is negative', async () => {
expect(() =>
fullJitterBackoffFactory({ baseDelay: 5, maxBackoffTime: -1 }).create()
).toThrowErrorMatchingInlineSnapshot(`"maxBackoffTime must not be negative"`);
});
it('starts with minimum of 1ms', () => {
const backoff = fullJitterBackoffFactory({ baseDelay: 0, maxBackoffTime: 4 }).create();
expect(backoff.nextBackOff()).toBeGreaterThanOrEqual(1);
});
it('caps based on the maxBackoffTime', () => {
const maxBackoffTime = 4;
const backoff = fullJitterBackoffFactory({ baseDelay: 1, maxBackoffTime }).create();
for (const _ of Array.from({ length: 1000 })) {
// maxBackoffTime plus the minimum 1ms
expect(backoff.nextBackOff()).toBeLessThanOrEqual(maxBackoffTime + 1);
}
});
it('caps retries', () => {
// 2^53 1
const maxBackoffTime = Number.MAX_SAFE_INTEGER;
// The ceiling for the tries is 2^32
const expectedCappedBackOff = Math.pow(2, 32);
const backoff = fullJitterBackoffFactory({ baseDelay: 1, maxBackoffTime }).create();
for (const _ of Array.from({ length: 1000 })) {
// maxBackoffTime plus the minimum 1ms
expect(backoff.nextBackOff()).toBeLessThanOrEqual(expectedCappedBackOff + 1);
}
});
it('returns a random number between the expected range correctly', () => {
const baseDelay = 5;
const maxBackoffTime = 2000;
// 2^11 = 4096 > maxBackoffTime
const totalTries = 12;
const backoff = fullJitterBackoffFactory({ baseDelay, maxBackoffTime }).create();
for (const index of Array.from(Array(totalTries).keys())) {
const maxExpectedRange = Math.min(maxBackoffTime, baseDelay * Math.pow(2, index));
const nextBackOff = backoff.nextBackOff();
expect(nextBackOff).toBeGreaterThanOrEqual(1);
expect(nextBackOff).toBeLessThanOrEqual(maxExpectedRange + 1);
}
});
});

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 type { BackoffFactory } from './types';
/**
* Implements the [Full Jitter Backoff algorithm](
* https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/)
*
*/
/**
* To prevent from overflows we cap the maximum number of retries.
* There must be 2 ^ currentTry <= 2 ^ 53 - 1.
* We cap it to 2 ^ 32.
*/
const CURRENT_TRY_CEILING = 32;
const getRandomIntegerFromInterval = (min: number, max: number) => {
return Math.floor(Math.random() * (max - min + 1) + min);
};
const throwIfNegative = (value: number, fieldName: string) => {
if (value < 0) {
throw new Error(`${fieldName} must not be negative`);
}
};
// Times are in ms
export const fullJitterBackoffFactory = ({
baseDelay,
maxBackoffTime,
}: {
baseDelay: number;
maxBackoffTime: number;
}): BackoffFactory => {
throwIfNegative(baseDelay, 'baseDelay');
throwIfNegative(maxBackoffTime, 'maxBackoffTime');
return {
create: () => {
let currentTry = 0;
return {
nextBackOff: () => {
const cappedCurrentTry = Math.min(CURRENT_TRY_CEILING, currentTry);
const sleep = Math.min(maxBackoffTime, baseDelay * Math.pow(2, cappedCurrentTry));
currentTry += 1;
// Minimum of 1 ms
return getRandomIntegerFromInterval(0, sleep) + 1;
},
};
},
};
};

View file

@ -0,0 +1,176 @@
/*
* 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 { Cases } from '../../../common';
import { CASE_RULES_SAVED_OBJECT } from '../../../common/constants';
import { mockCases } from '../../mocks';
import type { OracleRecord, OracleRecordError } from './types';
export const oracleRecord: OracleRecord = {
id: 'so-id',
version: 'so-version',
rules: [{ id: 'test-rule-id' }],
grouping: { 'host.ip': '0.0.0.1', 'agent.id': '8a4f500d' },
counter: 1,
createdAt: '2023-10-10T10:23:42.769Z',
updatedAt: '2023-10-10T10:23:42.769Z',
};
export const oracleRecordError: OracleRecordError = {
id: 'so-id',
error: 'An error',
statusCode: 404,
message: 'An error',
};
export const alerts = [
{
_id: 'alert-id-0',
_index: 'alert-index-0',
'host.name': 'A',
'dest.ip': '0.0.0.1',
'source.ip': '0.0.0.2',
},
{
_id: 'alert-id-1',
_index: 'alert-index-1',
'host.name': 'B',
'dest.ip': '0.0.0.1',
'file.hash': '12345',
},
{ _id: 'alert-id-2', _index: 'alert-index-2', 'host.name': 'A', 'dest.ip': '0.0.0.1' },
{ _id: 'alert-id-3', _index: 'alert-index-3', 'host.name': 'B', 'dest.ip': '0.0.0.3' },
];
export const alertsNested = [
{
_id: 'alert-id-0',
_index: 'alert-index-0',
host: { name: 'A' },
dest: { ip: '0.0.0.1' },
source: { ip: '0.0.0.2' },
},
{
_id: 'alert-id-1',
_index: 'alert-index-1',
host: { name: 'B' },
dest: { ip: '0.0.0.1' },
file: { hash: '12345' },
},
{
_id: 'alert-id-2',
_index: 'alert-index-2',
host: { name: 'A' },
dest: { ip: '0.0.0.1' },
},
{
_id: 'alert-id-3',
_index: 'alert-index-3',
host: { name: 'B' },
dest: { ip: '0.0.0.3' },
},
{
_id: 'alert-id-4',
_index: 'alert-index-4',
host: { name: 'A' },
source: { ip: '0.0.0.5' },
},
];
export const alertsWithNoGrouping = [
...alerts,
{ _id: 'alert-id-4', _index: 'alert-index-4', 'host.name': 'A', 'source.ip': '0.0.0.5' },
{ _id: 'alert-id-5', _index: 'alert-index-5' },
];
export const groupingBy = ['host.name', 'dest.ip'];
export const rule = {
id: 'rule-test-id',
name: 'Test rule',
tags: ['rule', 'test'],
ruleUrl: 'https://example.com/rules/rule-test-id',
};
export const owner = 'cases';
export const timeWindow = '7d';
export const reopenClosedCases = false;
export const groupedAlertsWithOracleKey = [
{
alerts: [alerts[0], alerts[2]],
grouping: { 'host.name': 'A', 'dest.ip': '0.0.0.1' },
oracleKey: 'so-oracle-record-0',
},
{
alerts: [alerts[1]],
grouping: { 'host.name': 'B', 'dest.ip': '0.0.0.1' },
oracleKey: 'so-oracle-record-1',
},
{
alerts: [alerts[3]],
grouping: { 'host.name': 'B', 'dest.ip': '0.0.0.3' },
oracleKey: 'so-oracle-record-2',
},
];
export const oracleRecords = [
{
id: groupedAlertsWithOracleKey[0].oracleKey,
version: 'so-version-0',
counter: 1,
cases: [],
rules: [],
grouping: groupedAlertsWithOracleKey[0].grouping,
createdAt: '2023-10-10T10:23:42.769Z',
updatedAt: '2023-10-10T10:23:42.769Z',
},
{
id: groupedAlertsWithOracleKey[1].oracleKey,
version: 'so-version-1',
counter: 1,
cases: [],
rules: [],
grouping: groupedAlertsWithOracleKey[1].grouping,
createdAt: '2023-10-12T10:23:42.769Z',
updatedAt: '2023-10-12T10:23:42.769Z',
},
{
id: groupedAlertsWithOracleKey[2].oracleKey,
type: CASE_RULES_SAVED_OBJECT,
message: 'Not found',
statusCode: 404,
error: 'Not found',
},
];
export const createdOracleRecord = {
...oracleRecords[0],
id: groupedAlertsWithOracleKey[2].oracleKey,
grouping: groupedAlertsWithOracleKey[2].grouping,
version: 'so-version-2',
createdAt: '2023-11-13T10:23:42.769Z',
updatedAt: '2023-11-13T10:23:42.769Z',
};
export const updatedCounterOracleRecord = {
...oracleRecords[0],
// another node increased the counter
counter: 2,
id: groupedAlertsWithOracleKey[0].oracleKey,
grouping: groupedAlertsWithOracleKey[0].grouping,
version: 'so-version-3',
createdAt: '2023-11-13T10:23:42.769Z',
updatedAt: '2023-11-13T10:23:42.769Z',
};
export const cases: Cases = mockCases.map((so) => ({
...so.attributes,
id: so.id,
version: so.version ?? '',
totalComment: 0,
totalAlerts: 0,
}));

View file

@ -0,0 +1,288 @@
/*
* 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 { SubActionConnectorType } from '@kbn/actions-plugin/server/sub_action_framework/types';
import type { CasesConnectorConfig, CasesConnectorSecrets } from './types';
import { getCasesConnectorAdapter, getCasesConnectorType } from '.';
import { AlertConsumers } from '@kbn/rule-data-utils';
describe('getCasesConnectorType', () => {
let caseConnectorType: SubActionConnectorType<CasesConnectorConfig, CasesConnectorSecrets>;
beforeEach(() => {
caseConnectorType = getCasesConnectorType({
getCasesClient: jest.fn(),
getUnsecuredSavedObjectsClient: jest.fn(),
getSpaceId: jest.fn(),
});
});
describe('getKibanaPrivileges', () => {
it('construct the kibana privileges correctly', () => {
expect(
caseConnectorType.getKibanaPrivileges?.({
params: { subAction: 'run', subActionParams: { owner: 'my-owner' } },
})
).toEqual([
'cases:my-owner/createCase',
'cases:my-owner/updateCase',
'cases:my-owner/deleteCase',
'cases:my-owner/pushCase',
'cases:my-owner/createComment',
'cases:my-owner/updateComment',
'cases:my-owner/deleteComment',
'cases:my-owner/findConfigurations',
]);
});
it('throws if the owner is undefined', () => {
expect(() => caseConnectorType.getKibanaPrivileges?.()).toThrowErrorMatchingInlineSnapshot(
`"Cannot authorize cases. Owner is not defined in the subActionParams."`
);
});
});
describe('getCasesConnectorAdapter', () => {
const alerts = {
all: {
data: [
{ _id: 'alert-id-1', _index: 'alert-index-1' },
{ _id: 'alert-id-2', _index: 'alert-index-2' },
],
count: 2,
},
new: { data: [{ _id: 'alert-id-1', _index: 'alert-index-1' }], count: 1 },
ongoing: { data: [{ _id: 'alert-id-2', _index: 'alert-index-2' }], count: 1 },
recovered: { data: [], count: 0 },
};
const rule = {
id: 'rule-id',
name: 'my rule name',
tags: ['my-tag'],
consumer: 'test-consumer',
};
const getParams = (overrides = {}) => ({
subAction: 'run' as const,
subActionParams: { groupingBy: [], reopenClosedCases: false, timeWindow: '7d', ...overrides },
});
it('sets the correct connectorTypeId', () => {
const adapter = getCasesConnectorAdapter();
expect(adapter.connectorTypeId).toEqual('.cases');
});
describe('ruleActionParamsSchema', () => {
it('validates getParams() correctly', () => {
const adapter = getCasesConnectorAdapter();
expect(adapter.ruleActionParamsSchema.validate(getParams())).toEqual(getParams());
});
it('throws if missing getParams()', () => {
const adapter = getCasesConnectorAdapter();
expect(() => adapter.ruleActionParamsSchema.validate({})).toThrow();
});
it('does not accept more than one groupingBy key', () => {
const adapter = getCasesConnectorAdapter();
expect(() =>
adapter.ruleActionParamsSchema.validate(
getParams({ groupingBy: ['host.name', 'source.ip'] })
)
).toThrow();
});
it('should fail with not valid time window', () => {
const adapter = getCasesConnectorAdapter();
expect(() =>
adapter.ruleActionParamsSchema.validate(getParams({ timeWindow: '10d+3d' }))
).toThrow();
});
});
describe('buildActionParams', () => {
it('builds the action getParams() correctly', () => {
const adapter = getCasesConnectorAdapter();
expect(
adapter.buildActionParams({
// @ts-expect-error: not all fields are needed
alerts,
rule,
params: getParams(),
spaceId: 'default',
ruleUrl: 'https://example.com',
})
).toMatchInlineSnapshot(`
Object {
"subAction": "run",
"subActionParams": Object {
"alerts": Array [
Object {
"_id": "alert-id-1",
"_index": "alert-index-1",
},
Object {
"_id": "alert-id-2",
"_index": "alert-index-2",
},
],
"groupingBy": Array [],
"maximumCasesToOpen": 5,
"owner": "cases",
"reopenClosedCases": false,
"rule": Object {
"id": "rule-id",
"name": "my rule name",
"ruleUrl": "https://example.com",
"tags": Array [
"my-tag",
],
},
"timeWindow": "7d",
},
}
`);
});
it('builds the action getParams() correctly without ruleUrl', () => {
const adapter = getCasesConnectorAdapter();
expect(
adapter.buildActionParams({
// @ts-expect-error: not all fields are needed
alerts,
rule,
params: getParams(),
spaceId: 'default',
})
).toMatchInlineSnapshot(`
Object {
"subAction": "run",
"subActionParams": Object {
"alerts": Array [
Object {
"_id": "alert-id-1",
"_index": "alert-index-1",
},
Object {
"_id": "alert-id-2",
"_index": "alert-index-2",
},
],
"groupingBy": Array [],
"maximumCasesToOpen": 5,
"owner": "cases",
"reopenClosedCases": false,
"rule": Object {
"id": "rule-id",
"name": "my rule name",
"ruleUrl": null,
"tags": Array [
"my-tag",
],
},
"timeWindow": "7d",
},
}
`);
});
it('maps observability consumers to the correct owner', () => {
const adapter = getCasesConnectorAdapter();
for (const consumer of [
AlertConsumers.OBSERVABILITY,
AlertConsumers.APM,
AlertConsumers.INFRASTRUCTURE,
AlertConsumers.LOGS,
AlertConsumers.SLO,
AlertConsumers.UPTIME,
AlertConsumers.MONITORING,
]) {
const connectorParams = adapter.buildActionParams({
// @ts-expect-error: not all fields are needed
alerts,
rule: { ...rule, consumer },
params: getParams(),
spaceId: 'default',
});
expect(connectorParams.subActionParams.owner).toBe('observability');
}
});
it('maps security solution consumers to the correct owner', () => {
const adapter = getCasesConnectorAdapter();
for (const consumer of [AlertConsumers.SIEM]) {
const connectorParams = adapter.buildActionParams({
// @ts-expect-error: not all fields are needed
alerts,
rule: { ...rule, consumer },
params: getParams(),
spaceId: 'default',
});
expect(connectorParams.subActionParams.owner).toBe('securitySolution');
}
});
it('maps stack consumers to the correct owner', () => {
const adapter = getCasesConnectorAdapter();
for (const consumer of [AlertConsumers.ML, AlertConsumers.STACK_ALERTS]) {
const connectorParams = adapter.buildActionParams({
// @ts-expect-error: not all fields are needed
alerts,
rule: { ...rule, consumer },
params: getParams(),
spaceId: 'default',
});
expect(connectorParams.subActionParams.owner).toBe('cases');
}
});
it('fallback to the cases owner if the consumer is not in the mapping', () => {
const adapter = getCasesConnectorAdapter();
const connectorParams = adapter.buildActionParams({
// @ts-expect-error: not all fields are needed
alerts,
rule: { ...rule, consumer: 'not-valid' },
params: getParams(),
spaceId: 'default',
});
expect(connectorParams.subActionParams.owner).toBe('cases');
});
});
describe('getKibanaPrivileges', () => {
it('constructs the correct privileges from the consumer', () => {
const adapter = getCasesConnectorAdapter();
expect(adapter.getKibanaPrivileges?.({ consumer: AlertConsumers.SIEM })).toEqual([
'cases:securitySolution/createCase',
'cases:securitySolution/updateCase',
'cases:securitySolution/deleteCase',
'cases:securitySolution/pushCase',
'cases:securitySolution/createComment',
'cases:securitySolution/updateComment',
'cases:securitySolution/deleteComment',
'cases:securitySolution/findConfigurations',
]);
});
});
});
});

View file

@ -0,0 +1,116 @@
/*
* 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 { AlertingConnectorFeatureId, UptimeConnectorFeatureId } from '@kbn/actions-plugin/common';
import type { SubActionConnectorType } from '@kbn/actions-plugin/server/sub_action_framework/types';
import type { KibanaRequest } from '@kbn/core-http-server';
import type { SavedObjectsClientContract } from '@kbn/core/server';
import type { ConnectorAdapter } from '@kbn/alerting-plugin/server';
import type { Owner } from '../../../common/constants/types';
import { CasesConnector } from './cases_connector';
import { DEFAULT_MAX_OPEN_CASES } from './constants';
import { CASES_CONNECTOR_ID, CASES_CONNECTOR_TITLE, OWNER_INFO } from '../../../common/constants';
import type {
CasesConnectorConfig,
CasesConnectorParams,
CasesConnectorRuleActionParams,
CasesConnectorSecrets,
} from './types';
import {
CasesConnectorConfigSchema,
CasesConnectorRuleActionParamsSchema,
CasesConnectorSecretsSchema,
} from './schema';
import type { CasesClient } from '../../client';
import { constructRequiredKibanaPrivileges } from './utils';
interface GetCasesConnectorTypeArgs {
getCasesClient: (request: KibanaRequest) => Promise<CasesClient>;
getUnsecuredSavedObjectsClient: (
request: KibanaRequest,
savedObjectTypes: string[]
) => Promise<SavedObjectsClientContract>;
getSpaceId: (request?: KibanaRequest) => string;
}
export const getCasesConnectorType = ({
getCasesClient,
getSpaceId,
getUnsecuredSavedObjectsClient,
}: GetCasesConnectorTypeArgs): SubActionConnectorType<
CasesConnectorConfig,
CasesConnectorSecrets
> => ({
id: CASES_CONNECTOR_ID,
name: CASES_CONNECTOR_TITLE,
getService: (params) =>
new CasesConnector({
casesParams: { getCasesClient, getSpaceId, getUnsecuredSavedObjectsClient },
connectorParams: params,
}),
schema: {
config: CasesConnectorConfigSchema,
secrets: CasesConnectorSecretsSchema,
},
supportedFeatureIds: [UptimeConnectorFeatureId, AlertingConnectorFeatureId],
minimumLicenseRequired: 'platinum' as const,
isSystemActionType: true,
getKibanaPrivileges: ({ params } = { params: { subAction: 'run', subActionParams: {} } }) => {
const owner = params?.subActionParams?.owner as string;
if (!owner) {
throw new Error('Cannot authorize cases. Owner is not defined in the subActionParams.');
}
return constructRequiredKibanaPrivileges(owner);
},
});
export const getCasesConnectorAdapter = (): ConnectorAdapter<
CasesConnectorRuleActionParams,
CasesConnectorParams
> => {
return {
connectorTypeId: CASES_CONNECTOR_ID,
ruleActionParamsSchema: CasesConnectorRuleActionParamsSchema,
buildActionParams: ({ alerts, rule, params, spaceId, ruleUrl }) => {
const caseAlerts = [...alerts.new.data, ...alerts.ongoing.data];
const owner = getOwnerFromRuleConsumer(rule.consumer);
const subActionParams = {
alerts: caseAlerts,
rule: { id: rule.id, name: rule.name, tags: rule.tags, ruleUrl: ruleUrl ?? null },
groupingBy: params.subActionParams.groupingBy,
owner,
reopenClosedCases: params.subActionParams.reopenClosedCases,
timeWindow: params.subActionParams.timeWindow,
maximumCasesToOpen: DEFAULT_MAX_OPEN_CASES,
};
return { subAction: 'run', subActionParams };
},
getKibanaPrivileges: ({ consumer }) => {
const owner = getOwnerFromRuleConsumer(consumer);
return constructRequiredKibanaPrivileges(owner);
},
};
};
const getOwnerFromRuleConsumer = (consumer: string): Owner => {
for (const value of Object.values(OWNER_INFO)) {
const foundedConsumer = value.validRuleConsumers?.find(
(validConsumer) => validConsumer === consumer
);
if (foundedConsumer) {
return value.id;
}
}
return OWNER_INFO.cases.id;
};

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 { loggingSystemMock } from '@kbn/core-logging-browser-mocks';
import type { Logger } from '@kbn/core/server';
import { CasesConnectorError } from './cases_connector_error';
import { CaseConnectorRetryService } from './retry_service';
import type { BackoffFactory } from './types';
describe('CryptoService', () => {
const nextBackOff = jest.fn();
const cb = jest.fn();
const backOffFactory: BackoffFactory = {
create: () => ({ nextBackOff }),
};
const mockLogger = loggingSystemMock.create().get() as jest.Mocked<Logger>;
let service: CaseConnectorRetryService;
beforeEach(() => {
jest.clearAllMocks();
nextBackOff.mockReturnValue(1);
service = new CaseConnectorRetryService(mockLogger, backOffFactory);
});
it('should not retry if the error is not CasesConnectorError', async () => {
cb.mockRejectedValue(new Error('My error'));
await expect(() => service.retryWithBackoff(cb)).rejects.toThrowErrorMatchingInlineSnapshot(
`"My error"`
);
expect(cb).toBeCalledTimes(1);
expect(nextBackOff).not.toBeCalled();
});
it('should not retry if the status code is not supported', async () => {
cb.mockRejectedValue(new CasesConnectorError('My case connector error', 500));
await expect(() => service.retryWithBackoff(cb)).rejects.toThrowErrorMatchingInlineSnapshot(
`"My case connector error"`
);
expect(cb).toBeCalledTimes(1);
expect(nextBackOff).not.toBeCalled();
});
it('should not retry after trying more than the max attempts', async () => {
const maxAttempts = 3;
service = new CaseConnectorRetryService(mockLogger, backOffFactory, maxAttempts);
cb.mockRejectedValue(new CasesConnectorError('My transient error', 409));
await expect(() => service.retryWithBackoff(cb)).rejects.toThrowErrorMatchingInlineSnapshot(
`"My transient error"`
);
expect(cb).toBeCalledTimes(maxAttempts + 1);
expect(nextBackOff).toBeCalledTimes(maxAttempts);
});
it.each([409, 429, 503])(
'should retry and succeed retryable status code: %s',
async (statusCode) => {
const maxAttempts = 3;
service = new CaseConnectorRetryService(mockLogger, backOffFactory, maxAttempts);
const error = new CasesConnectorError('My transient error', statusCode);
cb.mockRejectedValueOnce(error)
.mockRejectedValueOnce(error)
.mockResolvedValue({ status: 'ok' });
const res = await service.retryWithBackoff(cb);
expect(nextBackOff).toBeCalledTimes(maxAttempts - 1);
expect(cb).toBeCalledTimes(maxAttempts);
expect(res).toEqual({ status: 'ok' });
}
);
it('should succeed if cb does not throw', async () => {
service = new CaseConnectorRetryService(mockLogger, backOffFactory);
cb.mockResolvedValue({ status: 'ok' });
const res = await service.retryWithBackoff(cb);
expect(nextBackOff).toBeCalledTimes(0);
expect(cb).toBeCalledTimes(1);
expect(res).toEqual({ status: 'ok' });
});
describe('Logging', () => {
it('should log a warning when retrying', async () => {
service = new CaseConnectorRetryService(mockLogger, backOffFactory, 2);
cb.mockRejectedValue(new CasesConnectorError('My transient error', 409));
await expect(() => service.retryWithBackoff(cb)).rejects.toThrowErrorMatchingInlineSnapshot(
`"My transient error"`
);
expect(mockLogger.warn).toBeCalledTimes(2);
expect(mockLogger.warn).toHaveBeenNthCalledWith(
1,
'[CaseConnector] Case connector failed with status code 409. Attempt for retry: 1'
);
expect(mockLogger.warn).toHaveBeenNthCalledWith(
2,
'[CaseConnector] Case connector failed with status code 409. Attempt for retry: 2'
);
});
it('should not log a warning when the error is not supported', async () => {
cb.mockRejectedValue(new Error('My error'));
await expect(() => service.retryWithBackoff(cb)).rejects.toThrowErrorMatchingInlineSnapshot(
`"My error"`
);
expect(mockLogger.warn).not.toBeCalled();
});
});
});

View file

@ -0,0 +1,114 @@
/*
* 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 { Logger } from '@kbn/core/server';
import { CasesConnectorError } from './cases_connector_error';
import type { BackoffStrategy, BackoffFactory } from './types';
export class CaseConnectorRetryService {
private logger: Logger;
private maxAttempts: number;
/**
* 409 - Conflict
* 429 - Too Many Requests
* 503 - ES Unavailable
*
* Full list of errors: packages/core/saved-objects/core-saved-objects-server/src/saved_objects_error_helpers.ts
*/
private readonly RETRY_ERROR_STATUS_CODES: number[] = [409, 429, 503];
private readonly backOffStrategy: BackoffStrategy;
private timer: NodeJS.Timeout | null = null;
private attempt: number = 0;
constructor(logger: Logger, backOffFactory: BackoffFactory, maxAttempts: number = 10) {
this.logger = logger;
this.backOffStrategy = backOffFactory.create();
this.maxAttempts = maxAttempts;
}
public async retryWithBackoff<T>(cb: () => Promise<T>): Promise<T> {
try {
this.logger.debug(
`[CasesConnector][retryWithBackoff] Running case connector. Attempt: ${this.attempt}`,
{
labels: { attempt: this.attempt },
tags: ['case-connector:retry-start'],
}
);
const res = await cb();
this.logger.debug(
`[CasesConnector][retryWithBackoff] Case connector run successfully after ${this.attempt} attempts`,
{
labels: { attempt: this.attempt },
tags: ['case-connector:retry-success'],
}
);
return res;
} catch (error) {
if (this.shouldRetry() && this.isRetryableError(error)) {
this.stop();
this.attempt++;
await this.delay();
this.logger.warn(
`[CaseConnector] Case connector failed with status code ${error.statusCode}. Attempt for retry: ${this.attempt}`
);
return this.retryWithBackoff(cb);
}
throw error;
} finally {
this.logger.debug(
`[CasesConnector][retryWithBackoff] Case connector run ended after ${this.attempt} attempts`,
{
labels: { attempt: this.attempt },
tags: ['case-connector:retry-end'],
}
);
}
}
private shouldRetry() {
return this.attempt < this.maxAttempts;
}
private isRetryableError(error: Error) {
if (
error instanceof CasesConnectorError &&
this.RETRY_ERROR_STATUS_CODES.includes(error.statusCode)
) {
return true;
}
this.logger.debug(`[CasesConnector][isRetryableError] Error is not retryable`, {
tags: ['case-connector:retry-error'],
});
return false;
}
private async delay() {
const ms = this.backOffStrategy.nextBackOff();
return new Promise((resolve) => {
this.timer = setTimeout(resolve, ms);
});
}
private stop(): void {
if (this.timer !== null) {
clearTimeout(this.timer);
this.timer = null;
}
}
}

View file

@ -0,0 +1,196 @@
/*
* 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 { CasesConnectorRunParamsSchema } from './schema';
describe('CasesConnectorRunParamsSchema', () => {
const getParams = (overrides = {}) => ({
alerts: [{ _id: 'alert-id', _index: 'alert-index' }],
groupingBy: ['host.name'],
rule: { id: 'rule-id', name: 'Test rule', tags: [], ruleUrl: 'https://example.com' },
owner: 'cases',
...overrides,
});
it('accepts valid params', () => {
expect(CasesConnectorRunParamsSchema.validate(getParams())).toMatchInlineSnapshot(`
Object {
"alerts": Array [
Object {
"_id": "alert-id",
"_index": "alert-index",
},
],
"groupingBy": Array [
"host.name",
],
"maximumCasesToOpen": 5,
"owner": "cases",
"reopenClosedCases": false,
"rule": Object {
"id": "rule-id",
"name": "Test rule",
"ruleUrl": "https://example.com",
"tags": Array [],
},
"timeWindow": "7d",
}
`);
});
describe('alerts', () => {
it('throws if the alerts do not contain _id and _index', () => {
expect(() =>
CasesConnectorRunParamsSchema.validate(getParams({ alerts: [{ foo: 'bar' }] }))
).toThrow();
});
});
describe('groupingBy', () => {
it('accept an empty groupingBy array', () => {
expect(() =>
CasesConnectorRunParamsSchema.validate(getParams({ groupingBy: [] }))
).not.toThrow();
});
it('does not accept more than one groupingBy key', () => {
expect(() =>
CasesConnectorRunParamsSchema.validate(
getParams({ groupingBy: ['host.name', 'source.ip'] })
)
).toThrow();
});
});
describe('rule', () => {
it('accept empty tags', () => {
const params = getParams();
expect(() =>
CasesConnectorRunParamsSchema.validate({ ...params, rule: { ...params.rule, tags: [] } })
).not.toThrow();
});
it('does not accept an empty tag', () => {
const params = getParams();
expect(() =>
CasesConnectorRunParamsSchema.validate({
...params,
rule: { ...params.rule, tags: '' },
})
).toThrow();
});
});
describe('timeWindow', () => {
it('throws if the first digit starts with zero', () => {
expect(() =>
CasesConnectorRunParamsSchema.validate(getParams({ timeWindow: '0d' }))
).toThrow();
});
it('throws if the timeWindow does not start with a number', () => {
expect(() =>
CasesConnectorRunParamsSchema.validate(getParams({ timeWindow: 'd1' }))
).toThrow();
});
it('should fail for valid date math but not valid time window', () => {
expect(() =>
CasesConnectorRunParamsSchema.validate(getParams({ timeWindow: '10d+3d' }))
).toThrow();
});
it('throws if there is a non valid letter at the end', () => {
expect(() =>
CasesConnectorRunParamsSchema.validate(getParams({ timeWindow: '10d#' }))
).toThrow();
});
it('throws if there is a valid letter at the end', () => {
expect(() =>
CasesConnectorRunParamsSchema.validate(getParams({ timeWindow: '10dd' }))
).toThrow();
});
it('throws if there is a digit at the end', () => {
expect(() =>
CasesConnectorRunParamsSchema.validate(getParams({ timeWindow: '10d2' }))
).toThrow();
});
it('throws if there are two valid formats in sequence', () => {
expect(() =>
CasesConnectorRunParamsSchema.validate(getParams({ timeWindow: '1d2d' }))
).toThrow();
});
it('accepts double digit numbers', () => {
expect(() =>
CasesConnectorRunParamsSchema.validate(getParams({ timeWindow: '10d' }))
).not.toThrow();
});
it.each(['s', 'm', 'H', 'h', 'M', 'y'])('does not allow time unit %s', (unit) => {
expect(() =>
CasesConnectorRunParamsSchema.validate(getParams({ timeWindow: `5${unit}` }))
).toThrow();
});
it.each(['d', 'w'])('allows time unit %s', (unit) => {
expect(() =>
CasesConnectorRunParamsSchema.validate(getParams({ timeWindow: `5${unit}` }))
).not.toThrow();
});
it('defaults the timeWindow to 7d', () => {
expect(CasesConnectorRunParamsSchema.validate(getParams()).timeWindow).toBe('7d');
});
});
describe('reopenClosedCases', () => {
it('defaults the reopenClosedCases to false', () => {
expect(CasesConnectorRunParamsSchema.validate(getParams()).reopenClosedCases).toBe(false);
});
});
describe('maximumCasesToOpen', () => {
it('defaults the maximumCasesToOpen to 5', () => {
expect(CasesConnectorRunParamsSchema.validate(getParams()).maximumCasesToOpen).toBe(5);
});
it('sets the maximumCasesToOpen correctly', () => {
expect(
CasesConnectorRunParamsSchema.validate(getParams({ maximumCasesToOpen: 3 }))
.maximumCasesToOpen
).toBe(3);
});
it('does not accept maximumCasesToOpen to be zero', () => {
const params = getParams();
expect(() =>
CasesConnectorRunParamsSchema.validate({
...params,
maximumCasesToOpen: 0,
})
).toThrow();
});
it('does not accept maximumCasesToOpen to be more than 10', () => {
const params = getParams();
expect(() =>
CasesConnectorRunParamsSchema.validate({
...params,
maximumCasesToOpen: 11,
})
).toThrow();
});
});
});

View file

@ -0,0 +1,93 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { schema } from '@kbn/config-schema';
import dateMath from '@kbn/datemath';
import { MAX_OPEN_CASES, DEFAULT_MAX_OPEN_CASES } from './constants';
import { CASES_CONNECTOR_TIME_WINDOW_REGEX } from '../../../common/constants';
const AlertSchema = schema.recordOf(schema.string(), schema.any(), {
validate: (value) => {
if (!Object.hasOwn(value, '_id') || !Object.hasOwn(value, '_index')) {
return 'Alert ID and index must be defined';
}
},
});
/**
* At the moment only one field is supported for grouping
*/
const GroupingSchema = schema.arrayOf(schema.string(), { minSize: 0, maxSize: 1 });
const RuleSchema = schema.object({
id: schema.string(),
name: schema.string(),
tags: schema.arrayOf(schema.string(), { defaultValue: [] }),
ruleUrl: schema.nullable(schema.string()),
});
const ReopenClosedCasesSchema = schema.boolean({ defaultValue: false });
const TimeWindowSchema = schema.string({
defaultValue: '7d',
validate: (value) => {
/**
* Validates the time window.
* Acceptable format:
* - First character should be a digit from 1 to 9
* - All next characters should be a digit from 0 to 9
* - The last character should be d (day) or w (week)
*
* Example: 20d, 2w, etc
*/
const timeWindowRegex = new RegExp(CASES_CONNECTOR_TIME_WINDOW_REGEX, 'g');
if (!timeWindowRegex.test(value)) {
return 'Not a valid time window';
}
const date = dateMath.parse(`now-${value}`);
if (!date || !date.isValid()) {
return 'Not a valid time window';
}
},
});
/**
* The case connector does not have any configuration
* or secrets.
*/
export const CasesConnectorConfigSchema = schema.object({});
export const CasesConnectorSecretsSchema = schema.object({});
export const CasesConnectorRunParamsSchema = schema.object({
alerts: schema.arrayOf(AlertSchema),
groupingBy: GroupingSchema,
owner: schema.string(),
rule: RuleSchema,
timeWindow: TimeWindowSchema,
reopenClosedCases: ReopenClosedCasesSchema,
maximumCasesToOpen: schema.number({
defaultValue: DEFAULT_MAX_OPEN_CASES,
min: 1,
max: MAX_OPEN_CASES,
}),
});
export const CasesConnectorRuleActionParamsSchema = schema.object({
subAction: schema.literal('run'),
subActionParams: schema.object({
groupingBy: GroupingSchema,
reopenClosedCases: ReopenClosedCasesSchema,
timeWindow: TimeWindowSchema,
}),
});
export const CasesConnectorParamsSchema = schema.object({
subAction: schema.literal('run'),
subActionParams: CasesConnectorRunParamsSchema,
});

View file

@ -0,0 +1,116 @@
/*
* 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 { CasesClientMock } from '../../client/mocks';
export const expectCasesToHaveTheCorrectAlertsAttachedWithGrouping = (
casesClientMock: CasesClientMock
) => {
expect(casesClientMock.attachments.bulkCreate).toHaveBeenCalledTimes(3);
expect(casesClientMock.attachments.bulkCreate).nthCalledWith(1, {
caseId: 'mock-id-1',
attachments: [
{
alertId: ['alert-id-0', 'alert-id-2'],
index: ['alert-index-0', 'alert-index-2'],
owner: 'securitySolution',
rule: {
id: 'rule-test-id',
name: 'Test rule',
},
type: 'alert',
},
],
});
expect(casesClientMock.attachments.bulkCreate).nthCalledWith(2, {
caseId: 'mock-id-2',
attachments: [
{
alertId: ['alert-id-1'],
index: ['alert-index-1'],
owner: 'securitySolution',
rule: {
id: 'rule-test-id',
name: 'Test rule',
},
type: 'alert',
},
],
});
expect(casesClientMock.attachments.bulkCreate).nthCalledWith(3, {
caseId: 'mock-id-3',
attachments: [
{
alertId: ['alert-id-3'],
index: ['alert-index-3'],
owner: 'securitySolution',
rule: {
id: 'rule-test-id',
name: 'Test rule',
},
type: 'alert',
},
],
});
};
export const expectCasesToHaveTheCorrectAlertsAttachedWithGroupingAndIncreasedCounter = (
casesClientMock: CasesClientMock
) => {
expect(casesClientMock.attachments.bulkCreate).toHaveBeenCalledTimes(3);
expect(casesClientMock.attachments.bulkCreate).nthCalledWith(1, {
caseId: 'mock-id-1',
attachments: [
{
alertId: ['alert-id-1'],
index: ['alert-index-1'],
owner: 'securitySolution',
rule: {
id: 'rule-test-id',
name: 'Test rule',
},
type: 'alert',
},
],
});
expect(casesClientMock.attachments.bulkCreate).nthCalledWith(2, {
caseId: 'mock-id-2',
attachments: [
{
alertId: ['alert-id-3'],
index: ['alert-index-3'],
owner: 'securitySolution',
rule: {
id: 'rule-test-id',
name: 'Test rule',
},
type: 'alert',
},
],
});
expect(casesClientMock.attachments.bulkCreate).nthCalledWith(3, {
caseId: 'mock-id-4',
attachments: [
{
alertId: ['alert-id-0', 'alert-id-2'],
index: ['alert-index-0', 'alert-index-2'],
owner: 'securitySolution',
rule: {
id: 'rule-test-id',
name: 'Test rule',
},
type: 'alert',
},
],
});
};

View file

@ -0,0 +1,91 @@
/*
* 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 { ExclusiveUnion } from '@elastic/eui';
import type { TypeOf } from '@kbn/config-schema';
import type { SavedObjectError } from '@kbn/core-saved-objects-common';
import type { DecoratedError } from '@kbn/core-saved-objects-server';
import type {
CasesConnectorConfigSchema,
CasesConnectorSecretsSchema,
CasesConnectorRunParamsSchema,
CasesConnectorRuleActionParamsSchema,
CasesConnectorParamsSchema,
} from './schema';
export type CasesConnectorConfig = TypeOf<typeof CasesConnectorConfigSchema>;
export type CasesConnectorSecrets = TypeOf<typeof CasesConnectorSecretsSchema>;
export type CasesConnectorRunParams = Omit<
TypeOf<typeof CasesConnectorRunParamsSchema>,
'alerts'
> & { alerts: Array<{ _id: string; _index: string; [x: string]: unknown }> };
type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>;
interface OracleKeyAllRequired {
ruleId: string;
spaceId: string;
owner: string;
grouping: Record<string, unknown>;
}
type OracleKeyWithOptionalKey = Optional<OracleKeyAllRequired, 'ruleId'>;
type OracleKeyWithOptionalGrouping = Optional<OracleKeyAllRequired, 'grouping'>;
export type OracleKey = ExclusiveUnion<OracleKeyWithOptionalKey, OracleKeyWithOptionalGrouping>;
export type CaseIdPayload = OracleKey & { counter: number };
export interface OracleRecord {
id: string;
counter: number;
grouping: Record<string, unknown>;
rules: Array<{ id: string }>;
createdAt: string;
updatedAt: string | null;
version: string;
}
export type OracleSOError = SavedObjectError | DecoratedError;
export interface OracleRecordError {
id?: string;
error: string;
message: string;
statusCode: number;
}
export interface OracleRecordCreateRequest {
rules: Array<{ id: string }>;
grouping: Record<string, unknown>;
}
export type BulkGetOracleRecordsResponse = Array<OracleRecord | OracleRecordError>;
export type OracleRecordAttributes = Omit<OracleRecord, 'id' | 'version'>;
export type BulkCreateOracleRecordRequest = Array<{
recordId: string;
payload: OracleRecordCreateRequest;
}>;
export type BulkUpdateOracleRecordRequest = Array<{
recordId: string;
version: string;
payload: Pick<OracleRecordAttributes, 'counter'>;
}>;
export interface BackoffStrategy {
nextBackOff: () => number;
}
export interface BackoffFactory {
create: () => BackoffStrategy;
}
export type CasesConnectorRuleActionParams = TypeOf<typeof CasesConnectorRuleActionParamsSchema>;
export type CasesConnectorParams = TypeOf<typeof CasesConnectorParamsSchema>;

View file

@ -0,0 +1,255 @@
/*
* 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 {
CustomFieldConfiguration,
CustomFieldsConfiguration,
} from '../../../common/types/domain';
import { CustomFieldTypes } from '../../../common/types/domain';
import { oracleRecordError, oracleRecord } from './index.mock';
import {
convertValueToString,
isRecordError,
partitionRecordsByError,
buildRequiredCustomFieldsForRequest,
constructRequiredKibanaPrivileges,
} from './utils';
describe('utils', () => {
describe('isRecordError', () => {
it('returns true if the record contains an error', () => {
expect(isRecordError(oracleRecordError)).toBe(true);
});
it('returns false if the record is an oracle record', () => {
expect(isRecordError(oracleRecord)).toBe(false);
});
it('returns false if the record is an empty object', () => {
// @ts-expect-error: need to test for empty objects
expect(isRecordError({})).toBe(false);
});
});
describe('partitionRecordsByError', () => {
it('partition records correctly', () => {
expect(
partitionRecordsByError([oracleRecordError, oracleRecord, oracleRecordError, oracleRecord])
).toEqual([
[oracleRecord, oracleRecord],
[oracleRecordError, oracleRecordError],
]);
});
});
describe('convertValueToString', () => {
it('converts null correctly', () => {
expect(convertValueToString(null)).toBe('');
});
it('converts undefined correctly', () => {
expect(convertValueToString(undefined)).toBe('');
});
it('converts an array correctly', () => {
expect(convertValueToString([1, 2, 'foo', { foo: 'bar' }])).toBe('[1,2,"foo",{"foo":"bar"}]');
});
it('converts an object correctly', () => {
expect(convertValueToString({ foo: 'bar', baz: 2, qux: [1, 2, 'foo'] })).toBe(
'{"foo":"bar","baz":2,"qux":[1,2,"foo"]}'
);
});
it('converts a number correctly', () => {
expect(convertValueToString(5.2)).toBe('5.2');
});
it('converts a string correctly', () => {
expect(convertValueToString('foo')).toBe('foo');
});
it('converts a boolean correctly', () => {
expect(convertValueToString(true)).toBe('true');
});
});
describe('buildRequiredCustomFieldsForRequest', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('adds required custom fields with default values in configuration', () => {
const customFieldsConfiguration: CustomFieldsConfiguration = [
{
key: 'first_key',
type: CustomFieldTypes.TEXT,
label: 'text',
required: true,
defaultValue: 'default value',
},
{
key: 'second_key',
type: CustomFieldTypes.TOGGLE,
label: 'toggle',
required: true,
defaultValue: true,
},
];
expect(buildRequiredCustomFieldsForRequest(customFieldsConfiguration)).toEqual([
{
key: 'first_key',
type: CustomFieldTypes.TEXT as const,
value: 'default value',
},
{
key: 'second_key',
type: CustomFieldTypes.TOGGLE as const,
value: true,
},
]);
});
it('adds required custom fields without default values in configuration', () => {
const customFieldsConfiguration: CustomFieldsConfiguration = [
{
key: 'first_key',
type: CustomFieldTypes.TEXT,
label: 'text',
required: true,
},
{
key: 'second_key',
type: CustomFieldTypes.TOGGLE,
label: 'toggle',
required: true,
},
];
expect(buildRequiredCustomFieldsForRequest(customFieldsConfiguration)).toEqual([
{
key: 'first_key',
type: CustomFieldTypes.TEXT as const,
value: 'N/A',
},
{
key: 'second_key',
type: CustomFieldTypes.TOGGLE as const,
value: false,
},
]);
});
it('does not add optional fields with or without default values in configuration', () => {
const customFieldsConfiguration: CustomFieldsConfiguration = [
{
key: 'first_key',
type: CustomFieldTypes.TEXT,
label: 'text 1',
required: false,
defaultValue: 'default value',
},
{
key: 'second_key',
type: CustomFieldTypes.TOGGLE,
label: 'toggle 1',
required: false,
defaultValue: false,
},
{
key: 'third_key',
type: CustomFieldTypes.TEXT,
label: 'text 2',
required: false,
},
{
key: 'fourth_key',
type: CustomFieldTypes.TOGGLE,
label: 'toggle 2',
required: false,
},
];
expect(buildRequiredCustomFieldsForRequest(customFieldsConfiguration)).toEqual([]);
});
it('handles correctly a mix of required and optional custom fields', () => {
const customFieldsConfiguration: CustomFieldsConfiguration = [
{
key: 'first_key',
type: CustomFieldTypes.TEXT,
label: 'text 1',
required: false,
defaultValue: 'default value',
},
{
key: 'second_key',
type: CustomFieldTypes.TOGGLE,
label: 'toggle 1',
required: false,
defaultValue: false,
},
{
key: 'third_key',
type: CustomFieldTypes.TEXT,
label: 'text 2',
required: true,
},
{
key: 'fourth_key',
type: CustomFieldTypes.TOGGLE,
label: 'toggle 2',
required: false,
},
];
expect(buildRequiredCustomFieldsForRequest(customFieldsConfiguration)).toEqual([
{
key: 'third_key',
type: CustomFieldTypes.TEXT,
value: 'N/A',
},
]);
});
it('ensure we can generate for every possible custom field type', () => {
// this test should fail if a new custom field is added and the builder is not updated
const customFieldsConfiguration: CustomFieldsConfiguration = Object.keys(
CustomFieldTypes
).map(
(type) =>
({
key: `key-${type}`,
type,
label: `label-${type}`,
required: true,
// missing default value
} as CustomFieldConfiguration)
);
expect(buildRequiredCustomFieldsForRequest(customFieldsConfiguration).length).toEqual(
customFieldsConfiguration.length
);
});
});
describe('constructRequiredKibanaPrivileges', () => {
it('construct the required kibana privileges correctly', () => {
expect(constructRequiredKibanaPrivileges('my-owner')).toEqual([
'cases:my-owner/createCase',
'cases:my-owner/updateCase',
'cases:my-owner/deleteCase',
'cases:my-owner/pushCase',
'cases:my-owner/createComment',
'cases:my-owner/updateComment',
'cases:my-owner/deleteComment',
'cases:my-owner/findConfigurations',
]);
});
});
});

View file

@ -0,0 +1,97 @@
/*
* 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 { isPlainObject, partition, toString } from 'lodash';
import type { CaseRequestCustomField, CaseRequestCustomFields } from '../../../common/types/api';
import type { CustomFieldsConfiguration } from '../../../common/types/domain';
import { VALUES_FOR_CUSTOM_FIELDS_MISSING_DEFAULTS } from './constants';
import type { BulkGetOracleRecordsResponse, OracleRecord, OracleRecordError } from './types';
export const isRecordError = (so: OracleRecord | OracleRecordError): so is OracleRecordError =>
(so as OracleRecordError).error != null;
export const partitionRecordsByError = (
res: BulkGetOracleRecordsResponse
): [OracleRecord[], OracleRecordError[]] => {
const [errors, validRecords] = partition(res, isRecordError) as [
OracleRecordError[],
OracleRecord[]
];
return [validRecords, errors];
};
export const partitionByNonFoundErrors = <T extends Array<{ statusCode: number }>>(
errors: T
): [T, T] => {
const [nonFoundErrors, restOfErrors] = partition(errors, (error) => error.statusCode === 404) as [
T,
T
];
return [nonFoundErrors, restOfErrors];
};
export const convertValueToString = (value: unknown): string => {
if (value == null) {
return '';
}
if (Array.isArray(value) || isPlainObject(value)) {
try {
return JSON.stringify(value);
} catch (error) {
return '';
}
}
return toString(value);
};
export const buildRequiredCustomFieldsForRequest = (
customFieldsConfiguration?: CustomFieldsConfiguration
): CaseRequestCustomFields => {
// only populate with the default value required custom fields missing from the request
return customFieldsConfiguration
? customFieldsConfiguration
.filter((customFieldConfig) => customFieldConfig.required)
.map((customFieldConfig) => {
let value = null;
if (customFieldConfig.type in VALUES_FOR_CUSTOM_FIELDS_MISSING_DEFAULTS) {
value =
customFieldConfig.defaultValue === undefined ||
customFieldConfig?.defaultValue === null
? VALUES_FOR_CUSTOM_FIELDS_MISSING_DEFAULTS[customFieldConfig.type]
: customFieldConfig?.defaultValue;
}
return {
key: customFieldConfig.key,
type: customFieldConfig.type,
value,
} as CaseRequestCustomField;
})
: [];
};
export const constructRequiredKibanaPrivileges = (owner: string): string[] => {
/**
* Kibana features privileges are defined in
* x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/cases.ts
*/
return [
`cases:${owner}/createCase`,
`cases:${owner}/updateCase`,
`cases:${owner}/deleteCase`,
`cases:${owner}/pushCase`,
`cases:${owner}/createComment`,
`cases:${owner}/updateComment`,
`cases:${owner}/deleteComment`,
`cases:${owner}/findConfigurations`,
];
};

View file

@ -5,5 +5,56 @@
* 2.0.
*/
import type { PluginSetupContract as ActionsPluginSetupContract } from '@kbn/actions-plugin/server';
import type { KibanaRequest } from '@kbn/core-http-server';
import type { CoreSetup, SavedObjectsClientContract } from '@kbn/core/server';
import { SECURITY_EXTENSION_ID } from '@kbn/core/server';
import type { PluginSetupContract as AlertingPluginSetup } from '@kbn/alerting-plugin/server';
import type { CasesClient } from '../client';
import { getCasesConnectorAdapter, getCasesConnectorType } from './cases';
export * from './types';
export { casesConnectors } from './factory';
export function registerConnectorTypes({
alerting,
actions,
core,
getCasesClient,
getSpaceId,
}: {
actions: ActionsPluginSetupContract;
alerting: AlertingPluginSetup;
core: CoreSetup;
getCasesClient: (request: KibanaRequest) => Promise<CasesClient>;
getSpaceId: (request?: KibanaRequest) => string;
}) {
const getUnsecuredSavedObjectsClient = async (
request: KibanaRequest,
savedObjectTypes: string[]
): Promise<SavedObjectsClientContract> => {
const [coreStart] = await core.getStartServices();
/**
* The actions framework ensures that the user executing the case action
* will have permissions to use cases for the corresponding owner and space.
* The required Kibana privileges needed to execute the case action are defined
* in x-pack/plugins/cases/server/connectors/cases/index.ts.
*
* We can safely disable security checks performed by the saved object client
* as we implement our custom authorization.
*/
const unsecuredSavedObjectsClient = coreStart.savedObjects.getScopedClient(request, {
includedHiddenTypes: savedObjectTypes,
excludedExtensions: [SECURITY_EXTENSION_ID],
});
return unsecuredSavedObjectsClient;
};
actions.registerSubActionConnectorType(
getCasesConnectorType({ getCasesClient, getSpaceId, getUnsecuredSavedObjectsClient })
);
alerting.registerConnectorAdapter(getCasesConnectorAdapter());
}

View file

@ -48,6 +48,7 @@ describe('Cases Plugin', () => {
coreStart = coreMock.createStart();
pluginsSetup = {
alerting: alertsMock.createSetup(),
taskManager: taskManagerMock.createSetup(),
actions: actionsMock.createSetup(),
files: createFilesSetupMock(),

View file

@ -18,15 +18,8 @@ import type {
import type { SecurityPluginSetup } from '@kbn/security-plugin/server';
import type { LensServerPluginSetup } from '@kbn/lens-plugin/server';
import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common';
import { APP_ID } from '../common/constants';
import {
createCaseCommentSavedObjectType,
caseConfigureSavedObjectType,
caseConnectorMappingsSavedObjectType,
createCaseSavedObjectType,
createCaseUserActionSavedObjectType,
casesTelemetrySavedObjectType,
} from './saved_object_types';
import type { CasesClient } from './client';
import type {
@ -49,6 +42,8 @@ import { LICENSING_CASE_ASSIGNMENT_FEATURE } from './common/constants';
import { registerInternalAttachments } from './internal_attachments';
import { registerCaseFileKinds } from './files';
import type { ConfigType } from './config';
import { registerConnectorTypes } from './connectors';
import { registerSavedObjects } from './saved_object_types';
export class CasePlugin
implements
@ -90,6 +85,7 @@ export class CasePlugin
this.externalReferenceAttachmentTypeRegistry,
this.persistableStateAttachmentTypeRegistry
);
registerCaseFileKinds(this.caseConfig.files, plugins.files);
this.securityPluginSetup = plugins.security;
@ -99,23 +95,12 @@ export class CasePlugin
plugins.features.registerKibanaFeature(getCasesKibanaFeature());
}
core.savedObjects.registerType(
createCaseCommentSavedObjectType({
migrationDeps: {
persistableStateAttachmentTypeRegistry: this.persistableStateAttachmentTypeRegistry,
lensEmbeddableFactory: this.lensEmbeddableFactory,
},
})
);
core.savedObjects.registerType(caseConfigureSavedObjectType);
core.savedObjects.registerType(caseConnectorMappingsSavedObjectType);
core.savedObjects.registerType(createCaseSavedObjectType(core, this.logger));
core.savedObjects.registerType(
createCaseUserActionSavedObjectType({
persistableStateAttachmentTypeRegistry: this.persistableStateAttachmentTypeRegistry,
})
);
core.savedObjects.registerType(casesTelemetrySavedObjectType);
registerSavedObjects({
core,
logger: this.logger,
persistableStateAttachmentTypeRegistry: this.persistableStateAttachmentTypeRegistry,
lensEmbeddableFactory: this.lensEmbeddableFactory,
});
core.http.registerRouteHandlerContext<CasesRequestHandlerContext, 'cases'>(
APP_ID,
@ -147,6 +132,27 @@ export class CasePlugin
plugins.licensing.featureUsage.register(LICENSING_CASE_ASSIGNMENT_FEATURE, 'platinum');
const getCasesClient = async (request: KibanaRequest): Promise<CasesClient> => {
const [coreStart] = await core.getStartServices();
return this.getCasesClientWithRequest(coreStart)(request);
};
const getSpaceId = (request?: KibanaRequest) => {
if (!request) {
return DEFAULT_SPACE_ID;
}
return plugins.spaces?.spacesService.getSpaceId(request) ?? DEFAULT_SPACE_ID;
};
registerConnectorTypes({
actions: plugins.actions,
alerting: plugins.alerting,
core,
getCasesClient,
getSpaceId,
});
return {
attachmentFramework: {
registerExternalReference: (externalReferenceAttachmentType) => {
@ -198,18 +204,8 @@ export class CasePlugin
filesPluginStart: plugins.files,
});
const client = core.elasticsearch.client;
const getCasesClientWithRequest = async (request: KibanaRequest): Promise<CasesClient> => {
return this.clientFactory.create({
request,
scopedClusterClient: client.asScoped(request).asCurrentUser,
savedObjectsService: core.savedObjects,
});
};
return {
getCasesClientWithRequest,
getCasesClientWithRequest: this.getCasesClientWithRequest(core),
getExternalReferenceAttachmentTypeRegistry: () =>
this.externalReferenceAttachmentTypeRegistry,
getPersistableStateAttachmentTypeRegistry: () => this.persistableStateAttachmentTypeRegistry,
@ -240,4 +236,16 @@ export class CasePlugin
};
};
};
private getCasesClientWithRequest =
(core: CoreStart) =>
async (request: KibanaRequest): Promise<CasesClient> => {
const client = core.elasticsearch.client;
return this.clientFactory.create({
request,
scopedClusterClient: client.asScoped(request).asCurrentUser,
savedObjectsService: core.savedObjects,
});
};
}

View file

@ -20,7 +20,7 @@ export const patchCaseRoute = createCasesRoute({
const casesClient = await caseContext.getCasesClient();
const cases = request.body as caseApiV1.CasesPatchRequest;
const res: caseDomainV1.Cases = await casesClient.cases.update(cases);
const res: caseDomainV1.Cases = await casesClient.cases.bulkUpdate(cases);
return response.ok({
body: res,

View file

@ -0,0 +1,61 @@
/*
* 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 { SavedObjectsType } from '@kbn/core/server';
import { ALERTING_CASES_SAVED_OBJECT_INDEX } from '@kbn/core-saved-objects-server';
import { schema } from '@kbn/config-schema';
import { CASE_RULES_SAVED_OBJECT } from '../../common/constants';
export const casesRulesSavedObjectType: SavedObjectsType = {
name: CASE_RULES_SAVED_OBJECT,
indexPattern: ALERTING_CASES_SAVED_OBJECT_INDEX,
hidden: true,
namespaceType: 'agnostic',
mappings: {
dynamic: false,
properties: {
counter: {
type: 'unsigned_long',
},
createdAt: {
type: 'date',
},
/*
grouping: {
type: 'flattened',
},
*/
rules: {
properties: {
id: {
type: 'keyword',
},
},
},
updatedAt: {
type: 'date',
},
},
},
management: {
importableAndExportable: false,
},
modelVersions: {
'1': {
changes: [],
schemas: {
create: schema.object({
counter: schema.number(),
createdAt: schema.string(),
grouping: schema.recordOf(schema.string(), schema.any()),
rules: schema.arrayOf(schema.object({ id: schema.string() })),
updatedAt: schema.nullable(schema.string()),
}),
},
},
},
};

View file

@ -5,9 +5,48 @@
* 2.0.
*/
export { createCaseSavedObjectType } from './cases/cases';
export { caseConfigureSavedObjectType } from './configure';
export { createCaseCommentSavedObjectType } from './comments';
export { createCaseUserActionSavedObjectType } from './user_actions';
export { caseConnectorMappingsSavedObjectType } from './connector_mappings';
export { casesTelemetrySavedObjectType } from './telemetry';
import type { CoreSetup, Logger } from '@kbn/core/server';
import type { LensServerPluginSetup } from '@kbn/lens-plugin/server';
import { createCaseSavedObjectType } from './cases/cases';
import { caseConfigureSavedObjectType } from './configure';
import { createCaseCommentSavedObjectType } from './comments';
import { createCaseUserActionSavedObjectType } from './user_actions';
import { caseConnectorMappingsSavedObjectType } from './connector_mappings';
import { casesTelemetrySavedObjectType } from './telemetry';
import { casesRulesSavedObjectType } from './cases_rules';
import type { PersistableStateAttachmentTypeRegistry } from '../attachment_framework/persistable_state_registry';
interface RegisterSavedObjectsArgs {
core: CoreSetup;
logger: Logger;
persistableStateAttachmentTypeRegistry: PersistableStateAttachmentTypeRegistry;
lensEmbeddableFactory: LensServerPluginSetup['lensEmbeddableFactory'];
}
export const registerSavedObjects = ({
core,
logger,
persistableStateAttachmentTypeRegistry,
lensEmbeddableFactory,
}: RegisterSavedObjectsArgs) => {
core.savedObjects.registerType(
createCaseCommentSavedObjectType({
migrationDeps: {
persistableStateAttachmentTypeRegistry,
lensEmbeddableFactory,
},
})
);
core.savedObjects.registerType(caseConfigureSavedObjectType);
core.savedObjects.registerType(caseConnectorMappingsSavedObjectType);
core.savedObjects.registerType(createCaseSavedObjectType(core, logger));
core.savedObjects.registerType(
createCaseUserActionSavedObjectType({
persistableStateAttachmentTypeRegistry,
})
);
core.savedObjects.registerType(casesTelemetrySavedObjectType);
core.savedObjects.registerType(casesRulesSavedObjectType);
};

View file

@ -7,6 +7,7 @@
import { getAlertsTelemetryData } from './queries/alerts';
import { getCasesTelemetryData } from './queries/cases';
import { getCasesSystemActionData } from './queries/case_system_action';
import { getUserCommentsTelemetryData } from './queries/comments';
import { getConfigurationTelemetryData } from './queries/configuration';
import { getConnectorsTelemetryData } from './queries/connectors';
@ -19,16 +20,25 @@ export const collectTelemetryData = async ({
logger,
}: CollectTelemetryDataParams): Promise<Partial<CasesTelemetry>> => {
try {
const [cases, userActions, comments, alerts, connectors, pushes, configuration] =
await Promise.all([
getCasesTelemetryData({ savedObjectsClient, logger }),
getUserActionsTelemetryData({ savedObjectsClient, logger }),
getUserCommentsTelemetryData({ savedObjectsClient, logger }),
getAlertsTelemetryData({ savedObjectsClient, logger }),
getConnectorsTelemetryData({ savedObjectsClient, logger }),
getPushedTelemetryData({ savedObjectsClient, logger }),
getConfigurationTelemetryData({ savedObjectsClient, logger }),
]);
const [
cases,
userActions,
comments,
alerts,
connectors,
pushes,
configuration,
casesSystemAction,
] = await Promise.all([
getCasesTelemetryData({ savedObjectsClient, logger }),
getUserActionsTelemetryData({ savedObjectsClient, logger }),
getUserCommentsTelemetryData({ savedObjectsClient, logger }),
getAlertsTelemetryData({ savedObjectsClient, logger }),
getConnectorsTelemetryData({ savedObjectsClient, logger }),
getPushedTelemetryData({ savedObjectsClient, logger }),
getConfigurationTelemetryData({ savedObjectsClient, logger }),
getCasesSystemActionData({ savedObjectsClient, logger }),
]);
return {
cases,
@ -38,6 +48,7 @@ export const collectTelemetryData = async ({
connectors,
pushes,
configuration,
casesSystemAction,
};
} catch (err) {
logger.debug('Failed collecting Cases telemetry data');

View file

@ -21,6 +21,7 @@ import {
CASES_TELEMETRY_TASK_NAME,
CASE_TELEMETRY_SAVED_OBJECT_ID,
SAVED_OBJECT_TYPES,
CASE_RULES_SAVED_OBJECT,
} from '../../common/constants';
import type { CasesTelemetry } from './types';
import { casesSchema } from './schema';
@ -43,7 +44,11 @@ export const createCasesTelemetry = async ({
}: CreateCasesTelemetryArgs) => {
const getInternalSavedObjectClient = async (): Promise<ISavedObjectsRepository> => {
const [coreStart] = await core.getStartServices();
return coreStart.savedObjects.createInternalRepository([...SAVED_OBJECT_TYPES, FILE_SO_TYPE]);
return coreStart.savedObjects.createInternalRepository([
...SAVED_OBJECT_TYPES,
FILE_SO_TYPE,
CASE_RULES_SAVED_OBJECT,
]);
};
taskManager.registerTaskDefinitions({

View file

@ -0,0 +1,45 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { loggingSystemMock, savedObjectsRepositoryMock } from '@kbn/core/server/mocks';
import { getCasesSystemActionData } from './case_system_action';
describe('casesSystemAction', () => {
const logger = loggingSystemMock.createLogger();
describe('getCasesSystemActionData', () => {
const savedObjectsClient = savedObjectsRepositoryMock.create();
beforeEach(() => {
jest.clearAllMocks();
savedObjectsClient.find.mockResolvedValue({
total: 1,
saved_objects: [],
per_page: 1,
page: 1,
aggregations: { counterSum: { value: 4 }, totalRules: { value: 2 } },
});
});
it('calculates the metrics correctly', async () => {
const res = await getCasesSystemActionData({ savedObjectsClient, logger });
expect(res).toEqual({ totalCasesCreated: 4, totalRules: 2 });
});
it('calculates the metrics correctly with no aggregations', async () => {
savedObjectsClient.find.mockResolvedValue({
total: 1,
saved_objects: [],
per_page: 1,
page: 1,
});
const res = await getCasesSystemActionData({ savedObjectsClient, logger });
expect(res).toEqual({ totalCasesCreated: 0, totalRules: 0 });
});
});
});

View file

@ -0,0 +1,35 @@
/*
* 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 { CASE_RULES_SAVED_OBJECT } from '../../../common/constants';
import type { CasesTelemetry, CollectTelemetryDataParams } from '../types';
interface Aggs {
counterSum: { value: number };
totalRules: { value: number };
}
export const getCasesSystemActionData = async ({
savedObjectsClient,
}: CollectTelemetryDataParams): Promise<CasesTelemetry['casesSystemAction']> => {
const res = await savedObjectsClient.find<unknown, Aggs>({
page: 1,
perPage: 1,
type: CASE_RULES_SAVED_OBJECT,
aggs: {
counterSum: { sum: { field: `${CASE_RULES_SAVED_OBJECT}.attributes.counter` } },
totalRules: {
cardinality: { field: `${CASE_RULES_SAVED_OBJECT}.attributes.rules.id` },
},
},
});
return {
totalCasesCreated: res.aggregations?.counterSum?.value ?? 0,
totalRules: res.aggregations?.totalRules?.value ?? 0,
};
};

View file

@ -145,4 +145,8 @@ export const casesSchema: CasesTelemetrySchema = {
obs: customFieldsSolutionTelemetrySchema,
main: customFieldsSolutionTelemetrySchema,
},
casesSystemAction: {
totalCasesCreated: long,
totalRules: long,
},
};

View file

@ -213,6 +213,10 @@ export interface CasesTelemetry {
obs: CustomFieldsSolutionTelemetry;
main: CustomFieldsSolutionTelemetry;
};
casesSystemAction: {
totalCasesCreated: number;
totalRules: number;
};
}
export type CountSchema = MakeSchemaFrom<Count>;

View file

@ -18,7 +18,7 @@ import type {
PluginSetupContract as ActionsPluginSetup,
PluginStartContract as ActionsPluginStart,
} from '@kbn/actions-plugin/server';
import type { SpacesPluginStart } from '@kbn/spaces-plugin/server';
import type { SpacesPluginSetup, SpacesPluginStart } from '@kbn/spaces-plugin/server';
import type {
PluginStartContract as FeaturesPluginStart,
PluginSetupContract as FeaturesPluginSetup,
@ -32,12 +32,14 @@ import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server';
import type { LicensingPluginSetup, LicensingPluginStart } from '@kbn/licensing-plugin/server';
import type { NotificationsPluginStart } from '@kbn/notifications-plugin/server';
import type { RuleRegistryPluginStartContract } from '@kbn/rule-registry-plugin/server';
import type { PluginSetupContract as AlertingPluginSetup } from '@kbn/alerting-plugin/server';
import type { CasesClient } from './client';
import type { AttachmentFramework } from './attachment_framework/types';
import type { ExternalReferenceAttachmentTypeRegistry } from './attachment_framework/external_reference_registry';
import type { PersistableStateAttachmentTypeRegistry } from './attachment_framework/persistable_state_registry';
export interface CasesServerSetupDependencies {
alerting: AlertingPluginSetup;
actions: ActionsPluginSetup;
lens: LensServerPluginSetup;
features: FeaturesPluginSetup;
@ -46,6 +48,7 @@ export interface CasesServerSetupDependencies {
licensing: LicensingPluginSetup;
taskManager?: TaskManagerSetupContract;
usageCollection?: UsageCollectionSetup;
spaces?: SpacesPluginSetup;
}
export interface CasesServerStartDependencies {

View file

@ -70,6 +70,10 @@
"@kbn/core-application-browser",
"@kbn/react-kibana-context-render",
"@kbn/react-kibana-mount",
"@kbn/datemath",
"@kbn/core-logging-server-mocks",
"@kbn/core-logging-browser-mocks",
"@kbn/data-views-plugin",
],
"exclude": [
"target/**/*",

View file

@ -18,7 +18,7 @@ export type CasesSupportedOperations = typeof allOperations[number];
* extend the mapping here x-pack/plugins/features/server/feature_privilege_iterator/feature_privilege_iterator.ts
*
* Also if you add a new operation (createCase, updateCase, etc) here you'll likely also need to make changes here:
* x-pack/plugins/cases/server/authorization/index.ts
* x-pack/plugins/cases/server/authorization/index.ts and here x-pack/plugins/cases/server/connectors/cases/utils.ts
*/
const pushOperations = ['pushCase'] as const;

View file

@ -7084,6 +7084,16 @@
}
}
}
},
"casesSystemAction": {
"properties": {
"totalCasesCreated": {
"type": "long"
},
"totalRules": {
"type": "long"
}
}
}
}
},

View file

@ -571,6 +571,7 @@ export const ActionTypeForm = ({
actionConnector={actionConnector}
executionMode={ActionConnectorMode.ActionForm}
ruleTypeId={ruleTypeId}
producerId={producerId}
/>
{warning ? (
<>

View file

@ -195,6 +195,7 @@ export const SystemActionTypeForm = ({
actionConnector={actionConnector}
executionMode={ActionConnectorMode.ActionForm}
ruleTypeId={ruleTypeId}
producerId={producerId}
/>
{warning ? (
<>

View file

@ -4,7 +4,7 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import React, { ReactNode } from 'react';
import {
EuiText,
EuiSpacer,
@ -52,18 +52,20 @@ export function RuleActions({
);
}
const getNotifyText = (action: RuleUiAction, isSystemAction?: boolean) => {
const getNotifyText = (action: RuleUiAction, isSystemAction?: boolean): string | ReactNode => {
if (isSystemAction) {
return NOTIFY_WHEN_OPTIONS[1].inputDisplay;
}
return (
('frequency' in action &&
(NOTIFY_WHEN_OPTIONS.find((options) => options.value === action.frequency?.notifyWhen)
?.inputDisplay ||
action.frequency?.notifyWhen)) ??
legacyNotifyWhen
);
if ('frequency' in action) {
const notifyWhen = NOTIFY_WHEN_OPTIONS.find(
(options) => options.value === action.frequency?.notifyWhen
);
return notifyWhen?.inputDisplay ?? action.frequency?.notifyWhen ?? legacyNotifyWhen ?? '';
}
return '';
};
const getActionIconClass = (actionGroupId?: string): IconType | undefined => {
@ -85,6 +87,7 @@ export function RuleActions({
{ruleActions.map((action, index) => {
const { actionTypeId, id } = action;
const actionName = getActionName(id);
return (
<EuiFlexItem key={index}>
<EuiFlexGroup alignItems="center" gutterSize="s" component="span">
@ -105,7 +108,9 @@ export function RuleActions({
</EuiFlexItem>
<EuiFlexItem>
<EuiText
data-test-subj={`actionConnectorName-${index}-${actionName || actionTypeId}`}
data-test-subj={`actionConnectorName-notify-text${index}-${
actionName || actionTypeId
}`}
size="xs"
>
{String(

View file

@ -256,6 +256,7 @@ export interface ActionParamsProps<TParams> {
showEmailSubjectAndMessage?: boolean;
executionMode?: ActionConnectorMode;
onBlur?: (field?: string) => void;
producerId?: string;
}
export interface Pagination {

View file

@ -100,7 +100,7 @@ export default function ({ getService }: FtrProviderContext) {
const startDate = new Date().toISOString();
const connectorId = 'system-connector-test.system-action-kibana-privileges';
const name = 'System action: test.system-action-kibana-privileges';
const name = 'Test system action with kibana privileges';
const reference = `actions-enqueue-${scenario.id}:${space.id}:${connectorId}`;
const response = await supertestWithoutAuth

View file

@ -508,7 +508,7 @@ export default function ({ getService }: FtrProviderContext) {
it('should authorize system actions correctly', async () => {
const startDate = new Date().toISOString();
const connectorId = 'system-connector-test.system-action-kibana-privileges';
const name = 'System action: test.system-action-kibana-privileges';
const name = 'Test system action with kibana privileges';
const reference = `actions-enqueue-${scenario.id}:${space.id}:${connectorId}`;
/**

View file

@ -68,6 +68,15 @@ export default function getAllActionTests({ getService }: FtrProviderContext) {
(conn: { id: string }) => !conn.id.startsWith('custom.ssl.')
);
expect(nonCustomSslConnectors).to.eql([
{
connector_type_id: '.cases',
id: 'system-connector-.cases',
is_deprecated: false,
is_preconfigured: false,
is_system_action: true,
name: 'Cases',
referenced_by_count: 0,
},
{
id: createdAction.id,
is_preconfigured: false,
@ -126,13 +135,22 @@ export default function getAllActionTests({ getService }: FtrProviderContext) {
name: 'Slack#xyz',
referenced_by_count: 0,
},
{
id: 'custom-system-abc-connector',
is_preconfigured: true,
is_system_action: false,
is_deprecated: false,
connector_type_id: 'system-abc-action-type',
name: 'SystemABC',
referenced_by_count: 0,
},
{
connector_type_id: 'test.system-action',
id: 'system-connector-test.system-action',
is_deprecated: false,
is_preconfigured: false,
is_system_action: true,
name: 'System action: test.system-action',
name: 'Test system action',
referenced_by_count: 0,
},
{
@ -141,7 +159,7 @@ export default function getAllActionTests({ getService }: FtrProviderContext) {
is_deprecated: false,
is_preconfigured: false,
is_system_action: true,
name: 'System action: test.system-action-connector-adapter',
name: 'Test system action with a connector adapter set',
referenced_by_count: 0,
},
{
@ -150,16 +168,7 @@ export default function getAllActionTests({ getService }: FtrProviderContext) {
is_deprecated: false,
is_preconfigured: false,
is_system_action: true,
name: 'System action: test.system-action-kibana-privileges',
referenced_by_count: 0,
},
{
id: 'custom-system-abc-connector',
is_preconfigured: true,
is_system_action: false,
is_deprecated: false,
connector_type_id: 'system-abc-action-type',
name: 'SystemABC',
name: 'Test system action with kibana privileges',
referenced_by_count: 0,
},
{
@ -255,6 +264,15 @@ export default function getAllActionTests({ getService }: FtrProviderContext) {
(conn: { id: string }) => !conn.id.startsWith('custom.ssl.')
);
expect(nonCustomSslConnectors).to.eql([
{
connector_type_id: '.cases',
id: 'system-connector-.cases',
is_deprecated: false,
is_preconfigured: false,
is_system_action: true,
name: 'Cases',
referenced_by_count: 0,
},
{
id: createdAction.id,
is_preconfigured: false,
@ -313,33 +331,6 @@ export default function getAllActionTests({ getService }: FtrProviderContext) {
name: 'Slack#xyz',
referenced_by_count: 0,
},
{
connector_type_id: 'test.system-action',
id: 'system-connector-test.system-action',
is_deprecated: false,
is_preconfigured: false,
is_system_action: true,
name: 'System action: test.system-action',
referenced_by_count: 0,
},
{
connector_type_id: 'test.system-action-connector-adapter',
id: 'system-connector-test.system-action-connector-adapter',
is_deprecated: false,
is_preconfigured: false,
is_system_action: true,
name: 'System action: test.system-action-connector-adapter',
referenced_by_count: 0,
},
{
connector_type_id: 'test.system-action-kibana-privileges',
id: 'system-connector-test.system-action-kibana-privileges',
is_deprecated: false,
is_preconfigured: false,
is_system_action: true,
name: 'System action: test.system-action-kibana-privileges',
referenced_by_count: 0,
},
{
id: 'custom-system-abc-connector',
is_preconfigured: true,
@ -349,6 +340,34 @@ export default function getAllActionTests({ getService }: FtrProviderContext) {
name: 'SystemABC',
referenced_by_count: 0,
},
{
connector_type_id: 'test.system-action',
id: 'system-connector-test.system-action',
is_deprecated: false,
is_preconfigured: false,
is_system_action: true,
name: 'Test system action',
referenced_by_count: 0,
},
{
connector_type_id: 'test.system-action-connector-adapter',
id: 'system-connector-test.system-action-connector-adapter',
is_deprecated: false,
is_preconfigured: false,
is_system_action: true,
name: 'Test system action with a connector adapter set',
referenced_by_count: 0,
},
{
connector_type_id: 'test.system-action-kibana-privileges',
id: 'system-connector-test.system-action-kibana-privileges',
is_deprecated: false,
is_preconfigured: false,
is_system_action: true,
name: 'Test system action with kibana privileges',
referenced_by_count: 0,
},
{
id: 'preconfigured.test.index-record',
is_preconfigured: true,
@ -418,6 +437,15 @@ export default function getAllActionTests({ getService }: FtrProviderContext) {
(conn: { id: string }) => !conn.id.startsWith('custom.ssl.')
);
expect(nonCustomSslConnectors).to.eql([
{
connector_type_id: '.cases',
id: 'system-connector-.cases',
is_deprecated: false,
is_preconfigured: false,
is_system_action: true,
name: 'Cases',
referenced_by_count: 0,
},
{
connector_type_id: '.email',
id: 'notification-email',
@ -463,13 +491,22 @@ export default function getAllActionTests({ getService }: FtrProviderContext) {
name: 'Slack#xyz',
referenced_by_count: 0,
},
{
id: 'custom-system-abc-connector',
is_preconfigured: true,
is_system_action: false,
is_deprecated: false,
connector_type_id: 'system-abc-action-type',
name: 'SystemABC',
referenced_by_count: 0,
},
{
connector_type_id: 'test.system-action',
id: 'system-connector-test.system-action',
is_deprecated: false,
is_preconfigured: false,
is_system_action: true,
name: 'System action: test.system-action',
name: 'Test system action',
referenced_by_count: 0,
},
{
@ -478,7 +515,7 @@ export default function getAllActionTests({ getService }: FtrProviderContext) {
is_deprecated: false,
is_preconfigured: false,
is_system_action: true,
name: 'System action: test.system-action-connector-adapter',
name: 'Test system action with a connector adapter set',
referenced_by_count: 0,
},
{
@ -487,16 +524,7 @@ export default function getAllActionTests({ getService }: FtrProviderContext) {
is_deprecated: false,
is_preconfigured: false,
is_system_action: true,
name: 'System action: test.system-action-kibana-privileges',
referenced_by_count: 0,
},
{
id: 'custom-system-abc-connector',
is_preconfigured: true,
is_system_action: false,
is_deprecated: false,
connector_type_id: 'system-abc-action-type',
name: 'SystemABC',
name: 'Test system action with kibana privileges',
referenced_by_count: 0,
},
{

View file

@ -1880,7 +1880,7 @@ instanceStateValue: true
const space = SuperuserAtSpace1.space;
const connectorId = 'system-connector-test.system-action-connector-adapter';
const name = 'System action: test.system-action-connector-adapter';
const name = 'Test system action with a connector adapter set';
it('should use connector adapters correctly on system actions', async () => {
const alertUtils = new AlertUtils({

View file

@ -51,6 +51,7 @@ export default function createRegisteredConnectorTypeTests({ getService }: FtrPr
'.gen-ai',
'.bedrock',
'.sentinelone',
'.cases',
].sort()
);
});

View file

@ -342,7 +342,7 @@ export default function ({ getService }: FtrProviderContext) {
*/
it('should execute system actions correctly', async () => {
const connectorId = 'system-connector-test.system-action';
const name = 'System action: test.system-action';
const name = 'Test system action';
const response = await supertest
.post(
@ -375,7 +375,7 @@ export default function ({ getService }: FtrProviderContext) {
*/
it('should execute system actions with kibana privileges correctly', async () => {
const connectorId = 'system-connector-test.system-action-kibana-privileges';
const name = 'System action: test.system-action-kibana-privileges';
const name = 'Test system action with kibana privileges';
const response = await supertest
.post(

View file

@ -56,6 +56,15 @@ export default function getAllActionTests({ getService }: FtrProviderContext) {
is_system_action: false,
referenced_by_count: 0,
},
{
connector_type_id: '.cases',
id: 'system-connector-.cases',
is_deprecated: false,
is_preconfigured: false,
is_system_action: true,
name: 'Cases',
referenced_by_count: 0,
},
{
id: createdAction.id,
is_preconfigured: false,
@ -114,33 +123,6 @@ export default function getAllActionTests({ getService }: FtrProviderContext) {
name: 'Slack#xyz',
referenced_by_count: 0,
},
{
connector_type_id: 'test.system-action',
id: 'system-connector-test.system-action',
is_deprecated: false,
is_preconfigured: false,
is_system_action: true,
name: 'System action: test.system-action',
referenced_by_count: 0,
},
{
connector_type_id: 'test.system-action-connector-adapter',
id: 'system-connector-test.system-action-connector-adapter',
is_deprecated: false,
is_preconfigured: false,
is_system_action: true,
name: 'System action: test.system-action-connector-adapter',
referenced_by_count: 0,
},
{
connector_type_id: 'test.system-action-kibana-privileges',
id: 'system-connector-test.system-action-kibana-privileges',
is_deprecated: false,
is_preconfigured: false,
is_system_action: true,
name: 'System action: test.system-action-kibana-privileges',
referenced_by_count: 0,
},
{
id: 'custom-system-abc-connector',
is_preconfigured: true,
@ -150,6 +132,34 @@ export default function getAllActionTests({ getService }: FtrProviderContext) {
name: 'SystemABC',
referenced_by_count: 0,
},
{
connector_type_id: 'test.system-action',
id: 'system-connector-test.system-action',
is_deprecated: false,
is_preconfigured: false,
is_system_action: true,
name: 'Test system action',
referenced_by_count: 0,
},
{
connector_type_id: 'test.system-action-connector-adapter',
id: 'system-connector-test.system-action-connector-adapter',
is_deprecated: false,
is_preconfigured: false,
is_system_action: true,
name: 'Test system action with a connector adapter set',
referenced_by_count: 0,
},
{
connector_type_id: 'test.system-action-kibana-privileges',
id: 'system-connector-test.system-action-kibana-privileges',
is_deprecated: false,
is_preconfigured: false,
is_system_action: true,
name: 'Test system action with kibana privileges',
referenced_by_count: 0,
},
{
id: 'preconfigured.test.index-record',
is_preconfigured: true,
@ -208,6 +218,15 @@ export default function getAllActionTests({ getService }: FtrProviderContext) {
is_system_action: false,
referenced_by_count: 0,
},
{
connector_type_id: '.cases',
id: 'system-connector-.cases',
is_deprecated: false,
is_preconfigured: false,
is_system_action: true,
name: 'Cases',
referenced_by_count: 0,
},
{
connector_type_id: '.email',
id: 'notification-email',
@ -253,13 +272,22 @@ export default function getAllActionTests({ getService }: FtrProviderContext) {
name: 'Slack#xyz',
referenced_by_count: 0,
},
{
id: 'custom-system-abc-connector',
is_preconfigured: true,
is_deprecated: false,
is_system_action: false,
connector_type_id: 'system-abc-action-type',
name: 'SystemABC',
referenced_by_count: 0,
},
{
connector_type_id: 'test.system-action',
id: 'system-connector-test.system-action',
is_deprecated: false,
is_preconfigured: false,
is_system_action: true,
name: 'System action: test.system-action',
name: 'Test system action',
referenced_by_count: 0,
},
{
@ -268,7 +296,7 @@ export default function getAllActionTests({ getService }: FtrProviderContext) {
is_deprecated: false,
is_preconfigured: false,
is_system_action: true,
name: 'System action: test.system-action-connector-adapter',
name: 'Test system action with a connector adapter set',
referenced_by_count: 0,
},
{
@ -277,16 +305,7 @@ export default function getAllActionTests({ getService }: FtrProviderContext) {
is_deprecated: false,
is_preconfigured: false,
is_system_action: true,
name: 'System action: test.system-action-kibana-privileges',
referenced_by_count: 0,
},
{
id: 'custom-system-abc-connector',
is_preconfigured: true,
is_deprecated: false,
is_system_action: false,
connector_type_id: 'system-abc-action-type',
name: 'SystemABC',
name: 'Test system action with kibana privileges',
referenced_by_count: 0,
},
{

View file

@ -22,6 +22,7 @@ interface CreateTestConfigOptions {
}
const enabledActionTypes = [
'.cases',
'.cases-webhook',
'.email',
'.index',

View file

@ -11,7 +11,11 @@ import http from 'http';
import type SuperTest from 'supertest';
import { CASE_CONFIGURE_CONNECTORS_URL } from '@kbn/cases-plugin/common/constants';
import { getCaseConnectorsUrl } from '@kbn/cases-plugin/common/api';
import { ActionResult, FindActionResult } from '@kbn/actions-plugin/server/types';
import {
ActionResult,
ActionTypeExecutorResult,
FindActionResult,
} from '@kbn/actions-plugin/server/types';
import { getServiceNowServer } from '@kbn/actions-simulators-plugin/server/plugin';
import { RecordingServiceNowSimulator } from '@kbn/actions-simulators-plugin/server/servicenow_simulation';
import {
@ -21,6 +25,7 @@ import {
ConnectorTypes,
} from '@kbn/cases-plugin/common/types/domain';
import { CasePostRequest, GetCaseConnectorsResponse } from '@kbn/cases-plugin/common/types/api';
import { camelCase, mapKeys } from 'lodash';
import { User } from '../authentication/types';
import { superUser } from '../authentication/users';
import { getPostCaseRequest } from '../mock';
@ -316,3 +321,49 @@ export const getConnectors = async ({
return connectors;
};
export const executeConnector = async ({
supertest,
connectorId,
req,
expectedHttpCode = 200,
auth = { user: superUser, space: null },
}: {
supertest: SuperTest.SuperTest<SuperTest.Test>;
connectorId: string;
req: Record<string, unknown>;
expectedHttpCode?: number;
auth?: { user: User; space: string | null };
}): Promise<ActionTypeExecutorResult<unknown>> => {
const { body: res } = await supertest
.post(`${getSpaceUrlPrefix(auth.space)}/api/actions/connector/${connectorId}/_execute`)
.auth(auth.user.username, auth.user.password)
.set('kbn-xsrf', 'true')
.send(req)
.expect(expectedHttpCode);
return mapKeys(res, (_v, k) => camelCase(k)) as ActionTypeExecutorResult<unknown>;
};
export const executeSystemConnector = async ({
supertest,
connectorId,
req,
expectedHttpCode = 200,
auth = { user: superUser, space: null },
}: {
supertest: SuperTest.SuperTest<SuperTest.Test>;
connectorId: string;
req: Record<string, unknown>;
expectedHttpCode?: number;
auth?: { user: User; space: string | null };
}): Promise<ActionTypeExecutorResult<unknown>> => {
const { body: res } = await supertest
.post(`${getSpaceUrlPrefix(auth.space)}/api/cases_fixture/${connectorId}/connectors:execute`)
.auth(auth.user.username, auth.user.password)
.set('kbn-xsrf', 'true')
.send(req)
.expect(expectedHttpCode);
return mapKeys(res, (_v, k) => camelCase(k)) as ActionTypeExecutorResult<unknown>;
};

View file

@ -22,7 +22,7 @@ export const noKibanaPrivileges: Role = {
};
export const noCasesPrivilegesSpace1: Role = {
name: 'no_kibana_privileges',
name: 'no_cases_kibana_privileges',
privileges: {
elasticsearch: {
indices: [
@ -374,6 +374,29 @@ export const securitySolutionOnlyAllSpacesRole: Role = {
},
};
export const onlyActions: Role = {
name: 'only_actions',
privileges: {
elasticsearch: {
indices: [
{
names: ['*'],
privileges: ['all'],
},
],
},
kibana: [
{
feature: {
actions: ['all'],
actionsSimulators: ['all'],
},
spaces: ['*'],
},
],
},
};
export const roles = [
noKibanaPrivileges,
noCasesPrivilegesSpace1,
@ -390,4 +413,5 @@ export const roles = [
observabilityOnlyReadAlerts,
testDisabledPluginAll,
securitySolutionOnlyReadNoIndexAlerts,
onlyActions,
];

View file

@ -22,6 +22,7 @@ import {
securitySolutionOnlyReadNoIndexAlerts,
securitySolutionOnlyReadDelete,
noCasesConnectors as noCasesConnectorRole,
onlyActions as onlyActionsRole,
} from './roles';
import { User } from './types';
@ -122,8 +123,8 @@ export const noKibanaPrivileges: User = {
};
export const noCasesPrivilegesSpace1: User = {
username: 'no_kibana_privileges_space1',
password: 'no_kibana_privileges_space1',
username: 'no_cases_privileges_space1',
password: 'no_cases_privileges_space1',
roles: [noCasesPrivilegesSpace1Role.name],
};
@ -143,6 +144,12 @@ export const secOnlySpacesAll: User = {
roles: [securitySolutionOnlyAllSpacesRole.name],
};
export const onlyActions: User = {
username: 'only_actions',
password: 'only_actions',
roles: [onlyActionsRole.name],
};
export const users = [
superUser,
secOnly,
@ -162,4 +169,5 @@ export const users = [
noCasesPrivilegesSpace1,
testDisabled,
noCasesConnectors,
onlyActions,
];

Some files were not shown because too many files have changed in this diff Show more