Skip to content

Commit 15d0646

Browse files
committed
Wiring for bulk create in detection rules
1 parent 7eb7a70 commit 15d0646

12 files changed

Lines changed: 1016 additions & 39 deletions

File tree

x-pack/solutions/security/plugins/security_solution/common/experimental_features.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,16 @@ export const allowedExperimentalValues = Object.freeze({
2222
*/
2323
previewTelemetryUrlEnabled: false,
2424

25+
/**
26+
* When enabled, prebuilt rule installation (POST .../prebuilt_rules/installation/_perform)
27+
* and rule import (POST .../rules/_import) use the new alerting `rulesClient.bulkCreateRules`
28+
* path, which handles both disabled and enabled rules (API key minting + task scheduling)
29+
* in a single bulk call instead of the per-rule create loop.
30+
*
31+
* Release: TBD
32+
*/
33+
bulkCreateRulesEnabled: false,
34+
2535
/**
2636
* Enables extended rule execution logging to Event Log. When this setting is enabled:
2737
* - Rules write their console error, info, debug, and trace messages to Event Log,

x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_installation/perform_rule_installation_handler.ts

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -110,23 +110,33 @@ export const performRuleInstallationHandler = async (
110110
ruleInstallQueue.push(...(await excludeLicenseRestrictedRules(allInstallableRules, mlAuthz)));
111111
}
112112

113-
const BATCH_SIZE = 100;
114-
while (ruleInstallQueue.length > 0) {
115-
const rulesToInstall = ruleInstallQueue.splice(0, BATCH_SIZE);
116-
const ruleAssets = await ruleAssetsClient.fetchAssetsByVersion(rulesToInstall);
117-
118-
const { results, errors } = await createPrebuiltRules(
119-
detectionRulesClient,
120-
ruleAssets,
121-
logger
122-
);
123-
124-
const batchInstalledRules = results.map(({ result: rule }) =>
125-
pick(rule, ['id', 'rule_id', 'version'])
113+
const { bulkCreateRulesEnabled } = ctx.securitySolution.getConfig().experimentalFeatures;
114+
if (bulkCreateRulesEnabled) {
115+
const ruleAssets = await ruleAssetsClient.fetchAssetsByVersion(ruleInstallQueue);
116+
const { results, errors } = await detectionRulesClient.bulkCreatePrebuiltRules({
117+
rules: ruleAssets,
118+
});
119+
installedRules.push(
120+
...results.map(({ result: rule }) => pick(rule, ['id', 'rule_id', 'version']))
126121
);
127-
128-
installedRules.push(...batchInstalledRules);
129122
ruleErrors.push(...errors);
123+
} else {
124+
const BATCH_SIZE = 100;
125+
while (ruleInstallQueue.length > 0) {
126+
const rulesToInstall = ruleInstallQueue.splice(0, BATCH_SIZE);
127+
const ruleAssets = await ruleAssetsClient.fetchAssetsByVersion(rulesToInstall);
128+
129+
const { results, errors } = await createPrebuiltRules(
130+
detectionRulesClient,
131+
ruleAssets,
132+
logger
133+
);
134+
135+
installedRules.push(
136+
...results.map(({ result: rule }) => pick(rule, ['id', 'rule_id', 'version']))
137+
);
138+
ruleErrors.push(...errors);
139+
}
130140
}
131141

132142
const { error: timelineInstallationError } = await performTimelinesInstallation(

x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/import_rules/route.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ export const importRulesRoute = (
187187
ctx.securitySolution.getCheckOsqueryResponseActionAuthz(),
188188
});
189189

190+
const experimentalFeatures = ctx.securitySolution.getConfig().experimentalFeatures;
190191
const ruleChunks = chunk(CHUNK_PARSED_OBJECT_SIZE, validatedResponseActionsRules);
191192

192193
const importRuleResponse = await importRules({
@@ -195,6 +196,7 @@ export const importRulesRoute = (
195196
allowMissingConnectorSecrets: !!actionConnectors.length,
196197
ruleSourceImporter,
197198
detectionRulesClient,
199+
experimentalFeatures,
198200
});
199201

200202
const parseErrors = parsedRuleErrors.map((error) =>

x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/__mocks__/detection_rules_client.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const createDetectionRulesClientMock = () => {
1313
const mocked: DetectionRulesClientMock = {
1414
createCustomRule: jest.fn(),
1515
createPrebuiltRule: jest.fn(),
16+
bulkCreatePrebuiltRules: jest.fn().mockResolvedValue({ results: [], errors: [] }),
1617
updateRule: jest.fn(),
1718
patchRule: jest.fn(),
1819
deleteRule: jest.fn(),
@@ -21,6 +22,7 @@ const createDetectionRulesClientMock = () => {
2122
revertPrebuiltRule: jest.fn(),
2223
importRule: jest.fn(),
2324
importRules: jest.fn(),
25+
bulkImportRules: jest.fn().mockResolvedValue({ responses: [] }),
2426
getRuleCustomizationStatus: jest.fn(),
2527
getHistoryForRule: jest.fn(),
2628
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import { rulesClientMock } from '@kbn/alerting-plugin/server/mocks';
9+
import type { ActionsClient } from '@kbn/actions-plugin/server';
10+
import { savedObjectsClientMock } from '@kbn/core/server/mocks';
11+
import { licenseMock } from '@kbn/licensing-plugin/common/licensing.mock';
12+
13+
import { getCreateRulesSchemaMock } from '../../../../../../common/api/detection_engine/model/rule_schema/mocks';
14+
import { buildMlAuthz } from '../../../../machine_learning/authz';
15+
import { throwAuthzError } from '../../../../machine_learning/validation';
16+
import { createDetectionRulesClient } from './detection_rules_client';
17+
import type { IDetectionRulesClient } from './detection_rules_client_interface';
18+
import { createProductFeaturesServiceMock } from '../../../../product_features_service/mocks';
19+
import { getMockRulesAuthz } from '../../__mocks__/authz';
20+
21+
jest.mock('../../../../machine_learning/authz');
22+
jest.mock('../../../../machine_learning/validation');
23+
24+
describe('DetectionRulesClient.bulkCreatePrebuiltRules', () => {
25+
let rulesClient: ReturnType<typeof rulesClientMock.create>;
26+
let detectionRulesClient: IDetectionRulesClient;
27+
28+
const mlAuthz = (buildMlAuthz as jest.Mock)();
29+
const rulesAuthz = getMockRulesAuthz();
30+
const actionsClient: jest.Mocked<ActionsClient> = {
31+
isSystemAction: jest.fn(),
32+
} as unknown as jest.Mocked<ActionsClient>;
33+
34+
beforeEach(() => {
35+
rulesClient = rulesClientMock.create();
36+
(throwAuthzError as jest.Mock).mockReset();
37+
detectionRulesClient = createDetectionRulesClient({
38+
actionsClient,
39+
rulesClient,
40+
mlAuthz,
41+
rulesAuthz,
42+
savedObjectsClient: savedObjectsClientMock.create(),
43+
license: licenseMock.createLicenseMock(),
44+
productFeaturesService: createProductFeaturesServiceMock(),
45+
});
46+
});
47+
48+
it('issues a single bulkCreateRules call with disabled, immutable rules and emits { id, rule_id, version } pairs', async () => {
49+
const asset1 = { ...getCreateRulesSchemaMock(), version: 1, rule_id: 'rule-1' };
50+
const asset2 = { ...getCreateRulesSchemaMock(), version: 2, rule_id: 'rule-2' };
51+
52+
rulesClient.bulkCreateRules.mockImplementationOnce(async (args) => {
53+
const ids = args.rules.map((r) => (r.options as { id: string }).id);
54+
return { successfulIds: ids, errors: [], total: ids.length };
55+
});
56+
57+
const { results, errors } = await detectionRulesClient.bulkCreatePrebuiltRules({
58+
rules: [asset1, asset2],
59+
});
60+
61+
expect(rulesClient.bulkCreateRules).toHaveBeenCalledTimes(1);
62+
const callArgs = rulesClient.bulkCreateRules.mock.calls[0][0];
63+
expect(callArgs.rules).toHaveLength(2);
64+
expect(callArgs.rules.every((r) => r.data.enabled === false)).toBe(true);
65+
expect(callArgs.rules.every((r) => r.data.params.immutable === true)).toBe(true);
66+
expect(errors).toEqual([]);
67+
expect(results).toHaveLength(2);
68+
const callIds = callArgs.rules.map((r) => (r.options as { id: string }).id);
69+
expect(results[0].result).toEqual({ id: callIds[0], rule_id: 'rule-1', version: 1 });
70+
expect(results[1].result).toEqual({ id: callIds[1], rule_id: 'rule-2', version: 2 });
71+
});
72+
73+
it('issues a single bulkCreateRules call regardless of input size (alerting batches internally)', async () => {
74+
const assets = Array.from({ length: 250 }, (_, i) => ({
75+
...getCreateRulesSchemaMock(),
76+
version: 1,
77+
rule_id: `rule-${i}`,
78+
}));
79+
80+
rulesClient.bulkCreateRules.mockImplementationOnce(async (args) => ({
81+
successfulIds: args.rules.map((r) => (r.options as { id: string }).id),
82+
errors: [],
83+
total: args.rules.length,
84+
}));
85+
86+
const { results, errors } = await detectionRulesClient.bulkCreatePrebuiltRules({
87+
rules: assets,
88+
});
89+
90+
expect(rulesClient.bulkCreateRules).toHaveBeenCalledTimes(1);
91+
expect(results).toHaveLength(250);
92+
expect(errors).toEqual([]);
93+
});
94+
95+
it('reports per-rule errors from the alerting layer', async () => {
96+
const asset = { ...getCreateRulesSchemaMock(), version: 1, rule_id: 'rule-1' };
97+
98+
rulesClient.bulkCreateRules.mockImplementationOnce(async (args) => {
99+
const id = (args.rules[0].options as { id: string }).id;
100+
return {
101+
successfulIds: [],
102+
errors: [{ message: 'boom', status: 500, rule: { id, name: asset.name } }],
103+
total: 1,
104+
};
105+
});
106+
107+
const { results, errors } = await detectionRulesClient.bulkCreatePrebuiltRules({
108+
rules: [asset],
109+
});
110+
111+
expect(results).toEqual([]);
112+
expect(errors).toHaveLength(1);
113+
expect(errors[0].item).toBe(asset);
114+
expect(errors[0].error.message).toBe('boom');
115+
});
116+
117+
it('returns ML-auth failures as per-rule errors without calling bulkCreateRules', async () => {
118+
const asset = { ...getCreateRulesSchemaMock(), version: 1, rule_id: 'rule-1' };
119+
(throwAuthzError as jest.Mock).mockImplementation(() => {
120+
throw new Error('ML auth denied');
121+
});
122+
123+
const { results, errors } = await detectionRulesClient.bulkCreatePrebuiltRules({
124+
rules: [asset],
125+
});
126+
127+
expect(rulesClient.bulkCreateRules).not.toHaveBeenCalled();
128+
expect(results).toEqual([]);
129+
expect(errors[0].error.message).toBe('ML auth denied');
130+
});
131+
132+
it('returns empty result for empty input', async () => {
133+
const result = await detectionRulesClient.bulkCreatePrebuiltRules({ rules: [] });
134+
expect(result).toEqual({ results: [], errors: [] });
135+
expect(rulesClient.bulkCreateRules).not.toHaveBeenCalled();
136+
});
137+
});

0 commit comments

Comments
 (0)