mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[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:
parent
c837518650
commit
b735d8c569
106 changed files with 11526 additions and 229 deletions
|
@ -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',
|
||||
|
|
|
@ -195,6 +195,13 @@
|
|||
"cases-connector-mappings": [
|
||||
"owner"
|
||||
],
|
||||
"cases-rules": [
|
||||
"counter",
|
||||
"createdAt",
|
||||
"rules",
|
||||
"rules.id",
|
||||
"updatedAt"
|
||||
],
|
||||
"cases-telemetry": [],
|
||||
"cases-user-actions": [
|
||||
"action",
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -34,6 +34,7 @@ const previouslyRegisteredTypes = [
|
|||
'cases-comments',
|
||||
'cases-configure',
|
||||
'cases-connector-mappings',
|
||||
'cases-rules',
|
||||
'cases-sub-case',
|
||||
'cases-user-actions',
|
||||
'cases-telemetry',
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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: {},
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -29,4 +29,5 @@ export const connectorTypes: string[] = [
|
|||
'.bedrock',
|
||||
'.d3security',
|
||||
'.sentinelone',
|
||||
'.cases',
|
||||
];
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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> = {
|
||||
|
|
|
@ -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"
|
||||
],
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
26
x-pack/plugins/cases/common/constants/owner.test.ts
Normal file
26
x-pack/plugins/cases/common/constants/owner.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
|
|
|
@ -12,6 +12,7 @@
|
|||
"cases"
|
||||
],
|
||||
"requiredPlugins": [
|
||||
"alerting",
|
||||
"actions",
|
||||
"data",
|
||||
"embeddable",
|
||||
|
|
|
@ -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.'] },
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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 };
|
|
@ -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',
|
||||
}
|
|
@ -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 },
|
||||
});
|
|
@ -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;
|
||||
}
|
|
@ -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' },
|
||||
]);
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
};
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
]
|
||||
);
|
||||
}
|
|
@ -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());
|
|
@ -52,6 +52,7 @@ describe('Cases Ui Plugin', () => {
|
|||
},
|
||||
security: securityMock.createSetup(),
|
||||
management: managementPluginMock.createSetupContract(),
|
||||
triggersActionsUi: triggersActionsUiMock.createStart(),
|
||||
};
|
||||
|
||||
pluginsStart = {
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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: [
|
||||
{
|
|
@ -308,7 +308,7 @@ export interface UpdateRequestWithOriginalCase {
|
|||
*
|
||||
* @ignore
|
||||
*/
|
||||
export const update = async (
|
||||
export const bulkUpdate = async (
|
||||
cases: CasesPatchRequest,
|
||||
clientArgs: CasesClientArgs,
|
||||
casesClient: CasesClient
|
|
@ -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),
|
||||
|
|
|
@ -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(),
|
||||
|
|
579
x-pack/plugins/cases/server/connectors/cases/README.md
Normal file
579
x-pack/plugins/cases/server/connectors/cases/README.md
Normal 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).
|
|
@ -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();
|
||||
});
|
||||
});
|
210
x-pack/plugins/cases/server/connectors/cases/cases_connector.ts
Normal file
210
x-pack/plugins/cases/server/connectors/cases/cases_connector.ts
Normal 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',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
}
|
18
x-pack/plugins/cases/server/connectors/cases/constants.ts
Normal file
18
x-pack/plugins/cases/server/connectors/cases/constants.ts
Normal 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,
|
||||
};
|
|
@ -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(
|
||||
`{\"{}=:&\\\".'/{}}\":\"{}=:&\\\".'{}}\"}`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
176
x-pack/plugins/cases/server/connectors/cases/index.mock.ts
Normal file
176
x-pack/plugins/cases/server/connectors/cases/index.mock.ts
Normal 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,
|
||||
}));
|
288
x-pack/plugins/cases/server/connectors/cases/index.test.ts
Normal file
288
x-pack/plugins/cases/server/connectors/cases/index.test.ts
Normal 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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
116
x-pack/plugins/cases/server/connectors/cases/index.ts
Normal file
116
x-pack/plugins/cases/server/connectors/cases/index.ts
Normal 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;
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
114
x-pack/plugins/cases/server/connectors/cases/retry_service.ts
Normal file
114
x-pack/plugins/cases/server/connectors/cases/retry_service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
196
x-pack/plugins/cases/server/connectors/cases/schema.test.ts
Normal file
196
x-pack/plugins/cases/server/connectors/cases/schema.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
93
x-pack/plugins/cases/server/connectors/cases/schema.ts
Normal file
93
x-pack/plugins/cases/server/connectors/cases/schema.ts
Normal 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,
|
||||
});
|
116
x-pack/plugins/cases/server/connectors/cases/test_helpers.ts
Normal file
116
x-pack/plugins/cases/server/connectors/cases/test_helpers.ts
Normal 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',
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
91
x-pack/plugins/cases/server/connectors/cases/types.ts
Normal file
91
x-pack/plugins/cases/server/connectors/cases/types.ts
Normal 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>;
|
255
x-pack/plugins/cases/server/connectors/cases/utils.test.ts
Normal file
255
x-pack/plugins/cases/server/connectors/cases/utils.test.ts
Normal 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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
97
x-pack/plugins/cases/server/connectors/cases/utils.ts
Normal file
97
x-pack/plugins/cases/server/connectors/cases/utils.ts
Normal 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`,
|
||||
];
|
||||
};
|
|
@ -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());
|
||||
}
|
||||
|
|
|
@ -48,6 +48,7 @@ describe('Cases Plugin', () => {
|
|||
coreStart = coreMock.createStart();
|
||||
|
||||
pluginsSetup = {
|
||||
alerting: alertsMock.createSetup(),
|
||||
taskManager: taskManagerMock.createSetup(),
|
||||
actions: actionsMock.createSetup(),
|
||||
files: createFilesSetupMock(),
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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()),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
|
@ -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);
|
||||
};
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -145,4 +145,8 @@ export const casesSchema: CasesTelemetrySchema = {
|
|||
obs: customFieldsSolutionTelemetrySchema,
|
||||
main: customFieldsSolutionTelemetrySchema,
|
||||
},
|
||||
casesSystemAction: {
|
||||
totalCasesCreated: long,
|
||||
totalRules: long,
|
||||
},
|
||||
};
|
||||
|
|
|
@ -213,6 +213,10 @@ export interface CasesTelemetry {
|
|||
obs: CustomFieldsSolutionTelemetry;
|
||||
main: CustomFieldsSolutionTelemetry;
|
||||
};
|
||||
casesSystemAction: {
|
||||
totalCasesCreated: number;
|
||||
totalRules: number;
|
||||
};
|
||||
}
|
||||
|
||||
export type CountSchema = MakeSchemaFrom<Count>;
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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/**/*",
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -7084,6 +7084,16 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"casesSystemAction": {
|
||||
"properties": {
|
||||
"totalCasesCreated": {
|
||||
"type": "long"
|
||||
},
|
||||
"totalRules": {
|
||||
"type": "long"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -571,6 +571,7 @@ export const ActionTypeForm = ({
|
|||
actionConnector={actionConnector}
|
||||
executionMode={ActionConnectorMode.ActionForm}
|
||||
ruleTypeId={ruleTypeId}
|
||||
producerId={producerId}
|
||||
/>
|
||||
{warning ? (
|
||||
<>
|
||||
|
|
|
@ -195,6 +195,7 @@ export const SystemActionTypeForm = ({
|
|||
actionConnector={actionConnector}
|
||||
executionMode={ActionConnectorMode.ActionForm}
|
||||
ruleTypeId={ruleTypeId}
|
||||
producerId={producerId}
|
||||
/>
|
||||
{warning ? (
|
||||
<>
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -256,6 +256,7 @@ export interface ActionParamsProps<TParams> {
|
|||
showEmailSubjectAndMessage?: boolean;
|
||||
executionMode?: ActionConnectorMode;
|
||||
onBlur?: (field?: string) => void;
|
||||
producerId?: string;
|
||||
}
|
||||
|
||||
export interface Pagination {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}`;
|
||||
|
||||
/**
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -51,6 +51,7 @@ export default function createRegisteredConnectorTypeTests({ getService }: FtrPr
|
|||
'.gen-ai',
|
||||
'.bedrock',
|
||||
'.sentinelone',
|
||||
'.cases',
|
||||
].sort()
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
{
|
||||
|
|
|
@ -22,6 +22,7 @@ interface CreateTestConfigOptions {
|
|||
}
|
||||
|
||||
const enabledActionTypes = [
|
||||
'.cases',
|
||||
'.cases-webhook',
|
||||
'.email',
|
||||
'.index',
|
||||
|
|
|
@ -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>;
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
];
|
||||
|
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue