Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Cases] Case action: Phase 2 #169229

Merged
merged 45 commits into from
Nov 15, 2023
Merged
Show file tree
Hide file tree
Changes from 44 commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
0e1bfb0
Register the case action
cnasikas Oct 9, 2023
4777f9c
Register the cases oracle
cnasikas Oct 9, 2023
65a88fa
[CI] Auto-commit changed files from 'node scripts/check_mappings_upda…
kibanamachine Oct 9, 2023
eeb35a6
Calculate the hash of the record ID
cnasikas Oct 9, 2023
649dea9
Merge branch 'register_case_action' of github.com:cnasikas/kibana int…
cnasikas Oct 10, 2023
4491476
Get oracle record
cnasikas Oct 10, 2023
f4a81a1
Rename folder
cnasikas Oct 11, 2023
5822d73
Sort grouping definition
cnasikas Oct 11, 2023
df77537
Increase counter
cnasikas Oct 11, 2023
9955239
Change grouping to record
cnasikas Oct 12, 2023
58bc3d6
Make the rule ID optional in the key
cnasikas Oct 12, 2023
581819a
Better types
cnasikas Oct 13, 2023
449a1b7
Add version when updating
cnasikas Oct 13, 2023
c210a0c
Improve types
cnasikas Oct 13, 2023
c802637
Fix tests
cnasikas Oct 13, 2023
b249032
Merge branch 'case_action' into register_case_action
cnasikas Oct 16, 2023
c120c97
Add model version and improve mapping
cnasikas Oct 16, 2023
043a9fe
Fix tests
cnasikas Oct 16, 2023
a9db13f
Fix mapping test
cnasikas Oct 16, 2023
32af9e3
[CI] Auto-commit changed files from 'node scripts/check_mappings_upda…
kibanamachine Oct 16, 2023
90acabf
Merge branch 'case_action' into register_case_action
cnasikas Oct 18, 2023
b7605b7
Merge branch 'register_case_action' of github.com:cnasikas/kibana int…
cnasikas Oct 18, 2023
7b27008
Define connector params initial schema
cnasikas Oct 17, 2023
13fd013
Bulk get records
cnasikas Oct 17, 2023
69a8778
Group alerts and bulk get oracle records
cnasikas Oct 17, 2023
e5c73a3
[CI] Auto-commit changed files from 'node scripts/lint_ts_projects --…
kibanamachine Oct 18, 2023
1ba6117
Bulk create records
cnasikas Oct 18, 2023
bf8a32d
Merge branch 'ca_part_2' of github.com:cnasikas/kibana into ca_part_2
cnasikas Oct 18, 2023
c6982a5
Add TODOs
cnasikas Oct 19, 2023
f184a08
Move bulkGetOrCreateOracleRecords logic to the connector
cnasikas Oct 20, 2023
40f865e
Generate case ids
cnasikas Oct 20, 2023
f70bf1e
Get service with a factory
cnasikas Oct 21, 2023
04805ac
Fix docs
cnasikas Oct 21, 2023
a3d47e6
Merge branch 'main' into saf_get_instance
cnasikas Oct 21, 2023
3988644
Merge branch 'saf_get_instance' into ca_part_2
cnasikas Oct 21, 2023
af1b8ae
Pass the cases client to the case connector
cnasikas Oct 21, 2023
4c6f5ae
Attach alerts to a case
cnasikas Oct 23, 2023
a7f1ba1
Merge branch 'case_action' into register_case_action
cnasikas Oct 23, 2023
a283b93
Merge branch 'register_case_action' into ca_part_2
cnasikas Oct 23, 2023
8fd2a84
Merge branch 'case_action' into ca_part_2
cnasikas Nov 8, 2023
13012eb
Merge branch 'case_action' into ca_part_2
cnasikas Nov 8, 2023
c8e82e5
Bulk create non existing cases
cnasikas Nov 8, 2023
1ead049
Improve the case request
cnasikas Nov 10, 2023
b31c167
Small improvements
cnasikas Nov 10, 2023
462ce47
PR feedback
cnasikas Nov 14, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
507 changes: 507 additions & 0 deletions x-pack/plugins/cases/server/connectors/cases/cases_connector.test.ts

Large diffs are not rendered by default.

368 changes: 361 additions & 7 deletions x-pack/plugins/cases/server/connectors/cases/cases_connector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,77 @@
* 2.0.
*/

import stringify from 'json-stable-stringify';
import type { ServiceParams } from '@kbn/actions-plugin/server';
import { SubActionConnector } from '@kbn/actions-plugin/server';
import { CASES_CONNECTOR_SUB_ACTION } from './constants';
import type { CasesConnectorConfig, CasesConnectorSecrets } from './types';
import { CasesConnectorParamsSchema } from './schema';
import { pick } from 'lodash';
import type { KibanaRequest } from '@kbn/core-http-server';
import { CoreKibanaRequest } from '@kbn/core/server';
import pMap from 'p-map';
import type { BulkCreateCasesRequest } from '../../../common/types/api';
import type { Case } from '../../../common';
import { ConnectorTypes, AttachmentType } from '../../../common';
import { CASES_CONNECTOR_SUB_ACTION, MAX_CONCURRENT_REQUEST_ATTACH_ALERTS } from './constants';
import type {
BulkCreateOracleRecordRequest,
CasesConnectorConfig,
CasesConnectorRunParams,
CasesConnectorSecrets,
OracleRecord,
OracleRecordCreateRequest,
} from './types';
import { CasesConnectorRunParamsSchema } from './schema';
import { CasesOracleService } from './cases_oracle_service';
import { partitionRecords } from './utils';
import { CasesService } from './cases_service';
import type { CasesClient } from '../../client';
import type { BulkCreateArgs as BulkCreateAlertsReq } from '../../client/attachments/types';

interface CasesConnectorParams {
connectorParams: ServiceParams<CasesConnectorConfig, CasesConnectorSecrets>;
casesParams: { getCasesClient: (request: KibanaRequest) => Promise<CasesClient> };
}

interface GroupedAlerts {
alerts: CasesConnectorRunParams['alerts'];
grouping: Record<string, unknown>;
}

type GroupedAlertsWithOracleKey = GroupedAlerts & { oracleKey: string };
type GroupedAlertsWithCaseId = GroupedAlertsWithOracleKey & { caseId: string };
type GroupedAlertsWithCases = GroupedAlertsWithCaseId & { theCase: Case };

export class CasesConnector extends SubActionConnector<
CasesConnectorConfig,
CasesConnectorSecrets
> {
constructor(params: ServiceParams<CasesConnectorConfig, CasesConnectorSecrets>) {
super(params);
private readonly casesOracleService: CasesOracleService;
private readonly casesService: CasesService;
private readonly kibanaRequest: KibanaRequest;
private readonly casesParams: CasesConnectorParams['casesParams'];

constructor({ connectorParams, casesParams }: CasesConnectorParams) {
super(connectorParams);

this.casesOracleService = new CasesOracleService({
log: this.logger,
/**
* TODO: Think about permissions etc.
* Should we use our own savedObjectsClient as we do
* in the cases client? Should we so the createInternalRepository?
*/
unsecuredSavedObjectsClient: this.savedObjectsClient,
});

this.casesService = new CasesService();

/**
* TODO: Get request from the actions framework.
* Should be set in the SubActionConnector's constructor
*/
this.kibanaRequest = CoreKibanaRequest.from({ path: '/', headers: {} });

this.casesParams = casesParams;

this.registerSubActions();
}
Expand All @@ -25,7 +84,7 @@ export class CasesConnector extends SubActionConnector<
this.registerSubAction({
name: CASES_CONNECTOR_SUB_ACTION.RUN,
method: 'run',
schema: CasesConnectorParamsSchema,
schema: CasesConnectorRunParamsSchema,
});
}

Expand All @@ -38,5 +97,300 @@ export class CasesConnector extends SubActionConnector<
throw new Error('Method not implemented.');
}

public async run() {}
public async run(params: CasesConnectorRunParams) {
const { alerts, groupingBy } = params;
const casesClient = await this.casesParams.getCasesClient(this.kibanaRequest);

const groupedAlerts = this.groupAlerts({ alerts, groupingBy });
const groupedAlertsWithOracleKey = this.generateOracleKeys(params, groupedAlerts);

/**
* Add circuit breakers to the number of oracles they can be created or retrieved
*/
const oracleRecords = await this.bulkGetOrCreateOracleRecords(
Array.from(groupedAlertsWithOracleKey.values())
);

const groupedAlertsWithCaseId = this.generateCaseIds(
params,
groupedAlertsWithOracleKey,
oracleRecords
);

const groupedAlertsWithCases = await this.bulkGetOrCreateCases(
params,
casesClient,
groupedAlertsWithCaseId
);

await this.attachAlertsToCases(casesClient, groupedAlertsWithCases, params);
}

private groupAlerts({
alerts,
groupingBy,
}: Pick<CasesConnectorRunParams, 'alerts' | 'groupingBy'>): GroupedAlerts[] {
const uniqueGroupingByFields = Array.from(new Set<string>(groupingBy));
const groupingMap = new Map<string, GroupedAlerts>();

/**
* We are interested in alerts that have a value for any
* of the groupingBy fields defined by the users. All other
* alerts will not be attached to any case.
*/
const filteredAlerts = alerts.filter((alert) =>
uniqueGroupingByFields.every((groupingByField) => Object.hasOwn(alert, groupingByField))
);

for (const alert of filteredAlerts) {
const alertWithOnlyTheGroupingFields = pick(alert, uniqueGroupingByFields);
const groupingKey = stringify(alertWithOnlyTheGroupingFields);

if (groupingMap.has(groupingKey)) {
groupingMap.get(groupingKey)?.alerts.push(alert);
} else {
groupingMap.set(groupingKey, { alerts: [alert], grouping: alertWithOnlyTheGroupingFields });
}
}

return Array.from(groupingMap.values());
}

private generateOracleKeys(
params: CasesConnectorRunParams,
groupedAlerts: GroupedAlerts[]
): Map<string, GroupedAlertsWithOracleKey> {
const { rule, owner } = params;
/**
* TODO: Take spaceId from the actions framework
*/
const spaceId = 'default';

const oracleMap = new Map<string, GroupedAlertsWithOracleKey>();

for (const { grouping, alerts } of groupedAlerts) {
const oracleKey = this.casesOracleService.getRecordId({
ruleId: rule.id,
grouping,
owner,
spaceId,
});

oracleMap.set(oracleKey, { oracleKey, grouping, alerts });
}

return oracleMap;
}

private async bulkGetOrCreateOracleRecords(
groupedAlertsWithOracleKey: GroupedAlertsWithOracleKey[]
): Promise<OracleRecord[]> {
const bulkCreateReq: BulkCreateOracleRecordRequest = [];

const ids = groupedAlertsWithOracleKey.map(({ oracleKey }) => oracleKey);

const bulkGetRes = await this.casesOracleService.bulkGetRecords(ids);
const [bulkGetValidRecords, bulkGetRecordsErrors] = partitionRecords(bulkGetRes);

if (bulkGetRecordsErrors.length === 0) {
return bulkGetValidRecords;
}

const recordsMap = new Map<string, OracleRecordCreateRequest>(
groupedAlertsWithOracleKey.map(({ oracleKey, grouping }) => [
oracleKey,
// TODO: Add the rule info
{ cases: [], rules: [], grouping },
])
);

/**
* TODO: Throw/retry for other errors
*/
const nonFoundErrors = bulkGetRecordsErrors.filter((error) => error.statusCode === 404);

for (const error of nonFoundErrors) {
if (error.id && recordsMap.has(error.id)) {
bulkCreateReq.push({
recordId: error.id,
payload: recordsMap.get(error.id) ?? { cases: [], rules: [], grouping: {} },
});
}
}

const bulkCreateRes = await this.casesOracleService.bulkCreateRecord(bulkCreateReq);

/**
* TODO: Throw/Retry on errors
*/
const [bulkCreateValidRecords, _] = partitionRecords(bulkCreateRes);

return [...bulkGetValidRecords, ...bulkCreateValidRecords];
}

private generateCaseIds(
params: CasesConnectorRunParams,
groupedAlertsWithOracleKey: Map<string, GroupedAlertsWithOracleKey>,
oracleRecords: OracleRecord[]
): Map<string, GroupedAlertsWithCaseId> {
const { rule, owner } = params;

/**
* TODO: Take spaceId from the actions framework
*/
const spaceId = 'default';

const casesMap = new Map<string, GroupedAlertsWithCaseId>();

for (const oracleRecord of oracleRecords) {
const { alerts, grouping } = groupedAlertsWithOracleKey.get(oracleRecord.id) ?? {
alerts: [],
grouping: {},
};

const caseId = this.casesService.getCaseId({
ruleId: rule.id,
grouping,
owner,
spaceId,
counter: oracleRecord.counter,
});

casesMap.set(caseId, { caseId, alerts, grouping, oracleKey: oracleRecord.id });
}

return casesMap;
}

private async bulkGetOrCreateCases(
params: CasesConnectorRunParams,
casesClient: CasesClient,
groupedAlertsWithCaseId: Map<string, GroupedAlertsWithCaseId>
): Promise<Map<string, GroupedAlertsWithCases>> {
const bulkCreateReq: BulkCreateCasesRequest['cases'] = [];
const casesMap = new Map<string, GroupedAlertsWithCases>();

const ids = Array.from(groupedAlertsWithCaseId.values()).map(({ caseId }) => caseId);
const { cases, errors } = await casesClient.cases.bulkGet({ ids });

for (const theCase of cases) {
if (groupedAlertsWithCaseId.has(theCase.id)) {
const data = groupedAlertsWithCaseId.get(theCase.id) as GroupedAlertsWithCaseId;
casesMap.set(theCase.id, { ...data, theCase });
}
}

if (errors.length === 0) {
return casesMap;
}

/**
* TODO: Throw/retry for other errors
*/
const nonFoundErrors = errors.filter((error) => error.status === 404);

if (nonFoundErrors.length === 0) {
return casesMap;
}

for (const error of nonFoundErrors) {
if (groupedAlertsWithCaseId.has(error.caseId)) {
const data = groupedAlertsWithCaseId.get(error.caseId) as GroupedAlertsWithCaseId;

bulkCreateReq.push(this.getCreateCaseRequest(params, data));
}
}

/**
* TODO: bulkCreate throws an error. Retry on errors.
*/
const bulkCreateCasesResponse = await casesClient.cases.bulkCreate({ cases: bulkCreateReq });

for (const res of bulkCreateCasesResponse.cases) {
if (groupedAlertsWithCaseId.has(res.id)) {
const data = groupedAlertsWithCaseId.get(res.id) as GroupedAlertsWithCaseId;
casesMap.set(res.id, { ...data, theCase: res });
}
}

return casesMap;
}

private getCreateCaseRequest(
params: CasesConnectorRunParams,
groupingData: GroupedAlertsWithCaseId
) {
const { grouping } = groupingData;

const ruleName = params.rule.ruleUrl
? `[${params.rule.name}](${params.rule.ruleUrl})`
: params.rule.name;

const groupingDescription = this.getGroupingDescription(grouping);

const description = `This case is auto-created by ${ruleName}. \n\n Grouping: ${groupingDescription}`;

const tags = Array.isArray(params.rule.tags) ? params.rule.tags : [];

/**
* TODO: Add grouping info to
*/
return {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what about the required custom fields?

Copy link
Member Author

@cnasikas cnasikas Nov 14, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great point. I need to think about it. We need the default value feature. I will put a TODO comment to not forget about it.

description,
tags: ['auto-generated', ...tags],
/**
* TODO: Append the counter to the name
*/
title: `${params.rule.name} (Auto-created)`,
connector: { id: 'none', name: 'none', type: ConnectorTypes.none, fields: null },
/**
* Turn on for Security solution
*/
settings: { syncAlerts: false },
owner: params.owner,
};
}

private getGroupingDescription(grouping: GroupedAlerts['grouping']) {
/**
* TODO: Handle multi values
*/
return Object.entries(grouping)
.map(([key, value]) => {
const keyAsCodeBlock = `\`${key}\``;
const valueAsCodeBlock = `\`${value}\``;

return `${keyAsCodeBlock} equals ${valueAsCodeBlock}`;
})
.join(' and ');
}

private async attachAlertsToCases(
casesClient: CasesClient,
groupedAlertsWithCases: Map<string, GroupedAlertsWithCases>,
params: CasesConnectorRunParams
): Promise<void> {
const { rule } = params;

const bulkCreateAlertsRequest: BulkCreateAlertsReq[] = Array.from(
groupedAlertsWithCases.values()
).map(({ theCase, alerts }) => ({
caseId: theCase.id,
attachments: alerts.map((alert) => ({
type: AttachmentType.alert,
alertId: alert._id,
index: alert._index,
rule: { id: rule.id, name: rule.name },
owner: theCase.owner,
})),
}));

await pMap(
bulkCreateAlertsRequest,
(req: BulkCreateAlertsReq) => casesClient.attachments.bulkCreate(req),
{
concurrency: MAX_CONCURRENT_REQUEST_ATTACH_ALERTS,
}
);
}
}
Loading