From c5971b3551a152ff5ecad147354b72ff55d49b58 Mon Sep 17 00:00:00 2001
From: dej611
Date: Tue, 12 May 2026 12:45:11 +0200
Subject: [PATCH 001/193] :sparkles: New integration check first step
---
.../pipelines/workflows_examples_dryrun.yml | 12 +++
.../steps/workflows/validate_examples.sh | 59 +++++++++++++
.buildkite/workflows_examples_ref | 9 ++
package.json | 1 +
scripts/validate_workflow_examples.js | 11 +++
.../kbn-workflows-examples-cli/README.md | 55 ++++++++++++
.../kbn-workflows-examples-cli/index.ts | 63 +++++++++++++
.../kbn-workflows-examples-cli/jest.config.js | 14 +++
.../kbn-workflows-examples-cli/kibana.jsonc | 8 ++
.../kbn-workflows-examples-cli/moon.yml | 39 ++++++++
.../kbn-workflows-examples-cli/package.json | 6 ++
.../src/build_schema.ts | 24 +++++
.../src/discover_examples.ts | 42 +++++++++
.../src/junit_report.ts | 86 ++++++++++++++++++
.../src/run_validation.ts | 88 +++++++++++++++++++
.../src/validate_example.test.ts | 54 ++++++++++++
.../src/validate_example.ts | 57 ++++++++++++
.../kbn-workflows-examples-cli/tsconfig.json | 17 ++++
.../yaml/parse_workflow_yaml_to_json.ts | 15 ++--
19 files changed, 652 insertions(+), 8 deletions(-)
create mode 100644 .buildkite/pipelines/workflows_examples_dryrun.yml
create mode 100755 .buildkite/scripts/steps/workflows/validate_examples.sh
create mode 100644 .buildkite/workflows_examples_ref
create mode 100644 scripts/validate_workflow_examples.js
create mode 100644 src/platform/packages/shared/kbn-workflows-examples-cli/README.md
create mode 100644 src/platform/packages/shared/kbn-workflows-examples-cli/index.ts
create mode 100644 src/platform/packages/shared/kbn-workflows-examples-cli/jest.config.js
create mode 100644 src/platform/packages/shared/kbn-workflows-examples-cli/kibana.jsonc
create mode 100644 src/platform/packages/shared/kbn-workflows-examples-cli/moon.yml
create mode 100644 src/platform/packages/shared/kbn-workflows-examples-cli/package.json
create mode 100644 src/platform/packages/shared/kbn-workflows-examples-cli/src/build_schema.ts
create mode 100644 src/platform/packages/shared/kbn-workflows-examples-cli/src/discover_examples.ts
create mode 100644 src/platform/packages/shared/kbn-workflows-examples-cli/src/junit_report.ts
create mode 100644 src/platform/packages/shared/kbn-workflows-examples-cli/src/run_validation.ts
create mode 100644 src/platform/packages/shared/kbn-workflows-examples-cli/src/validate_example.test.ts
create mode 100644 src/platform/packages/shared/kbn-workflows-examples-cli/src/validate_example.ts
create mode 100644 src/platform/packages/shared/kbn-workflows-examples-cli/tsconfig.json
diff --git a/.buildkite/pipelines/workflows_examples_dryrun.yml b/.buildkite/pipelines/workflows_examples_dryrun.yml
new file mode 100644
index 0000000000000..b132ccf0277b0
--- /dev/null
+++ b/.buildkite/pipelines/workflows_examples_dryrun.yml
@@ -0,0 +1,12 @@
+steps:
+ - command: .buildkite/scripts/steps/workflows/validate_examples.sh
+ label: Validate elastic/workflows examples against the current Kibana schema
+ timeout_in_minutes: 20
+ agents:
+ image: family/kibana-ubuntu-2404
+ imageProject: elastic-images-prod
+ provider: gcp
+ machineType: n2-standard-2
+ preemptible: true
+ artifact_paths:
+ - target/workflow-examples-junit.xml
diff --git a/.buildkite/scripts/steps/workflows/validate_examples.sh b/.buildkite/scripts/steps/workflows/validate_examples.sh
new file mode 100755
index 0000000000000..6ab57c8f04fbc
--- /dev/null
+++ b/.buildkite/scripts/steps/workflows/validate_examples.sh
@@ -0,0 +1,59 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+REF_FILE="$KIBANA_DIR/.buildkite/workflows_examples_ref"
+WORKFLOWS_REPO_URL="${WORKFLOWS_REPO_URL:-https://github.com/elastic/workflows}"
+WORKFLOWS_CHECKOUT="$PARENT_DIR/workflows"
+EXAMPLES_SUBPATH="${WORKFLOWS_EXAMPLES_SUBPATH:-examples}"
+JUNIT_OUT="$KIBANA_DIR/target/workflow-examples-junit.xml"
+
+report_main_step () {
+ echo "--- $1"
+}
+
+read_ref () {
+ if [[ ! -f "$REF_FILE" ]]; then
+ echo "Error: $REF_FILE is missing." >&2
+ echo "The pinned ref file must exist; refusing to clone an arbitrary upstream." >&2
+ exit 1
+ fi
+ # shellcheck source=/dev/null
+ source "$REF_FILE"
+ if [[ -z "${WORKFLOWS_REF:-}" ]]; then
+ echo "Error: WORKFLOWS_REF is empty in $REF_FILE." >&2
+ exit 1
+ fi
+ if [[ ! "$WORKFLOWS_REF" =~ ^[0-9a-f]{40}$ ]]; then
+ echo "Warning: WORKFLOWS_REF='$WORKFLOWS_REF' is not a 40-char commit SHA." >&2
+ echo "Prefer a pinned SHA so PRs racing the bump are reproducible." >&2
+ fi
+}
+
+main () {
+ read_ref
+
+ report_main_step "Cloning elastic/workflows @ $WORKFLOWS_REF"
+ rm -rf "$WORKFLOWS_CHECKOUT"
+ git clone --filter=blob:none "$WORKFLOWS_REPO_URL" "$WORKFLOWS_CHECKOUT"
+ git -C "$WORKFLOWS_CHECKOUT" checkout --detach "$WORKFLOWS_REF"
+
+ report_main_step "Bootstrapping Kibana"
+ cd "$KIBANA_DIR"
+ .buildkite/scripts/bootstrap.sh
+
+ local examples_dir="$WORKFLOWS_CHECKOUT/$EXAMPLES_SUBPATH"
+ if [[ ! -d "$examples_dir" ]]; then
+ echo "Error: examples directory '$examples_dir' does not exist." >&2
+ echo "Override the subpath with WORKFLOWS_EXAMPLES_SUBPATH if upstream layout changed." >&2
+ exit 1
+ fi
+
+ mkdir -p "$(dirname "$JUNIT_OUT")"
+
+ report_main_step "Validating workflow examples"
+ node scripts/validate_workflow_examples.js \
+ --dir "$examples_dir" \
+ --junit-out "$JUNIT_OUT"
+}
+
+main
diff --git a/.buildkite/workflows_examples_ref b/.buildkite/workflows_examples_ref
new file mode 100644
index 0000000000000..b9a74dc694dfc
--- /dev/null
+++ b/.buildkite/workflows_examples_ref
@@ -0,0 +1,9 @@
+# Pinned ref of https://github.com/elastic/workflows used by the
+# workflows-examples-dryrun pipeline.
+#
+# Prefer a full 40-char commit SHA so PR runs are reproducible and a malicious
+# upstream commit can't influence build outputs. Renovate or a separate sync
+# job is expected to bump this file. A branch name (`main`) is accepted as a
+# bootstrap fallback only — the runner emits a warning in that case.
+
+WORKFLOWS_REF=main
diff --git a/package.json b/package.json
index 4df900c99a998..7b8e2e19a9cb7 100644
--- a/package.json
+++ b/package.json
@@ -1263,6 +1263,7 @@
"@kbn/visualizations-plugin": "link:src/platform/plugins/shared/visualizations",
"@kbn/watcher-plugin": "link:x-pack/platform/plugins/private/watcher",
"@kbn/workflows": "link:src/platform/packages/shared/kbn-workflows",
+ "@kbn/workflows-examples-cli": "link:src/platform/packages/shared/kbn-workflows-examples-cli",
"@kbn/workflows-execution-engine": "link:src/platform/plugins/shared/workflows_execution_engine",
"@kbn/workflows-extensions": "link:src/platform/plugins/shared/workflows_extensions",
"@kbn/workflows-extensions-example": "link:examples/workflows_extensions_example",
diff --git a/scripts/validate_workflow_examples.js b/scripts/validate_workflow_examples.js
new file mode 100644
index 0000000000000..814968771622a
--- /dev/null
+++ b/scripts/validate_workflow_examples.js
@@ -0,0 +1,11 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+require('@kbn/setup-node-env');
+require('@kbn/workflows-examples-cli').runValidateExamplesCli();
diff --git a/src/platform/packages/shared/kbn-workflows-examples-cli/README.md b/src/platform/packages/shared/kbn-workflows-examples-cli/README.md
new file mode 100644
index 0000000000000..288a7aa7dfffb
--- /dev/null
+++ b/src/platform/packages/shared/kbn-workflows-examples-cli/README.md
@@ -0,0 +1,55 @@
+# @kbn/workflows-examples-cli
+
+Static validation for workflow YAML examples.
+
+Used in CI to detect when YAML parsing or basic schema constraints break the
+example workflows published in
+[`elastic/workflows`](https://github.com/elastic/workflows). Runs without
+booting Kibana.
+
+## Usage
+
+```
+node scripts/validate_workflow_examples --dir [--junit-out ]
+```
+
+- `--dir` (required): directory of `.yml`/`.yaml` files. Walked recursively;
+ dotfiles and hidden directories are skipped.
+- `--junit-out` (optional): writes a JUnit XML report for Buildkite to pick up.
+
+Exits non-zero on any failure.
+
+## What this CLI actually catches
+
+- YAML syntax errors (parser-level failures).
+- Examples exceeding `MAX_WORKFLOW_YAML_LENGTH`.
+- Structural schema regressions on the **public** workflow definition —
+ top-level `name`, `enabled`, `triggers`, `inputs`, `settings`, `steps` —
+ built from `getElasticsearchConnectors()` + `getKibanaConnectors()` with
+ `loose: true`.
+
+## What this CLI does **not** catch
+
+Step types contributed by the `workflows_management` plugin's
+`stack_connectors_schema` (`slack`, `http`, `inference`, `virustotal`,
+`ai.agent`, `kibana.*` sub-actions, etc.) are *not* visible from this package.
+The CLI treats unknown step types as schema errors, so any example using one
+of those step types **will fail** static validation here.
+
+For real coverage of connector params drift and step-type renames, examples
+must be exercised against a running Kibana with the plugin's full schema. That
+work is tracked separately as the "stub-connector full dry-run" plan.
+
+This CLI is therefore a **fast-fail gate**: it catches the cheapest classes of
+regression on every PR without needing a Kibana stack. It does not replace
+end-to-end validation.
+
+## Programmatic use
+
+```ts
+import {
+ runValidation,
+ validateExampleYaml,
+ buildPublicWorkflowSchema,
+} from '@kbn/workflows-examples-cli';
+```
diff --git a/src/platform/packages/shared/kbn-workflows-examples-cli/index.ts b/src/platform/packages/shared/kbn-workflows-examples-cli/index.ts
new file mode 100644
index 0000000000000..f435a82a53efd
--- /dev/null
+++ b/src/platform/packages/shared/kbn-workflows-examples-cli/index.ts
@@ -0,0 +1,63 @@
+/*
+ * 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+import Path from 'path';
+import { mkdir, writeFile } from 'fs/promises';
+import { run } from '@kbn/dev-cli-runner';
+import { createFailError } from '@kbn/dev-cli-errors';
+
+import { runValidation } from './src/run_validation';
+import { renderJUnitXml } from './src/junit_report';
+
+export { runValidation } from './src/run_validation';
+export { validateExampleYaml } from './src/validate_example';
+export type { ValidationOutcome, SchemaIssue } from './src/validate_example';
+export { buildPublicWorkflowSchema } from './src/build_schema';
+export { discoverExampleFiles } from './src/discover_examples';
+export { renderJUnitXml } from './src/junit_report';
+export type { ExampleResult } from './src/junit_report';
+
+export function runValidateExamplesCli(): void {
+ run(
+ async ({ flagsReader, log }) => {
+ const dir = flagsReader.requiredString('dir');
+ const rootDir = Path.resolve(dir);
+ const junitOutFlag = flagsReader.string('junit-out');
+
+ const summary = await runValidation({ rootDir, log });
+
+ if (junitOutFlag) {
+ const junitPath = Path.resolve(junitOutFlag);
+ await mkdir(Path.dirname(junitPath), { recursive: true });
+ await writeFile(junitPath, renderJUnitXml(summary.results), 'utf8');
+ log.info(`Wrote JUnit report to ${junitPath}`);
+ }
+
+ if (summary.failed > 0) {
+ throw createFailError(
+ `${summary.failed} of ${summary.results.length} workflow example(s) failed validation`
+ );
+ }
+ },
+ {
+ description:
+ 'Validate workflow YAML examples (from elastic/workflows or any directory) against the Kibana workflow schema.',
+ usage: 'node scripts/validate_workflow_examples --dir [--junit-out ]',
+ flags: {
+ string: ['dir', 'junit-out'],
+ help: `
+ --dir (required) Directory containing workflow YAML examples (.yml/.yaml).
+ The directory is walked recursively; dotfiles and hidden directories
+ are skipped.
+ --junit-out Optional path to write a JUnit XML report (consumed by Buildkite).
+ `,
+ },
+ }
+ );
+}
diff --git a/src/platform/packages/shared/kbn-workflows-examples-cli/jest.config.js b/src/platform/packages/shared/kbn-workflows-examples-cli/jest.config.js
new file mode 100644
index 0000000000000..58760b1bcf439
--- /dev/null
+++ b/src/platform/packages/shared/kbn-workflows-examples-cli/jest.config.js
@@ -0,0 +1,14 @@
+/*
+ * 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+module.exports = {
+ preset: '@kbn/test/jest_node',
+ rootDir: '../../../../..',
+ roots: ['/src/platform/packages/shared/kbn-workflows-examples-cli'],
+};
diff --git a/src/platform/packages/shared/kbn-workflows-examples-cli/kibana.jsonc b/src/platform/packages/shared/kbn-workflows-examples-cli/kibana.jsonc
new file mode 100644
index 0000000000000..de26573adb401
--- /dev/null
+++ b/src/platform/packages/shared/kbn-workflows-examples-cli/kibana.jsonc
@@ -0,0 +1,8 @@
+{
+ "type": "shared-common",
+ "id": "@kbn/workflows-examples-cli",
+ "owner": "@elastic/workflows-eng",
+ "group": "platform",
+ "visibility": "shared",
+ "devOnly": true
+}
diff --git a/src/platform/packages/shared/kbn-workflows-examples-cli/moon.yml b/src/platform/packages/shared/kbn-workflows-examples-cli/moon.yml
new file mode 100644
index 0000000000000..37b0701ebee22
--- /dev/null
+++ b/src/platform/packages/shared/kbn-workflows-examples-cli/moon.yml
@@ -0,0 +1,39 @@
+# This file is generated by the @kbn/moon package. Any manual edits will be erased!
+# To extend this, write your extensions/overrides to 'moon.extend.yml'
+# then regenerate this file with: 'node scripts/regenerate_moon_projects.js --update --filter @kbn/workflows-examples-cli'
+
+$schema: https://moonrepo.dev/schemas/project.json
+id: '@kbn/workflows-examples-cli'
+layer: unknown
+owners:
+ defaultOwner: '@elastic/workflows-eng'
+toolchains:
+ default: node
+language: typescript
+project:
+ title: '@kbn/workflows-examples-cli'
+ description: Moon project for @kbn/workflows-examples-cli
+ channel: ''
+ owner: '@elastic/workflows-eng'
+ sourceRoot: src/platform/packages/shared/kbn-workflows-examples-cli
+dependsOn:
+ - '@kbn/dev-cli-runner'
+ - '@kbn/dev-cli-errors'
+ - '@kbn/workflows'
+ - '@kbn/workflows-yaml'
+ - '@kbn/tooling-log'
+ - '@kbn/zod'
+tags:
+ - shared-common
+ - package
+ - dev
+ - group-platform
+ - shared
+ - jest-unit-tests
+fileGroups:
+ src:
+ - '**/*.ts'
+ - '!target/**/*'
+ jest-config:
+ - jest.config.js
+tasks: {}
diff --git a/src/platform/packages/shared/kbn-workflows-examples-cli/package.json b/src/platform/packages/shared/kbn-workflows-examples-cli/package.json
new file mode 100644
index 0000000000000..40b0511a9599c
--- /dev/null
+++ b/src/platform/packages/shared/kbn-workflows-examples-cli/package.json
@@ -0,0 +1,6 @@
+{
+ "name": "@kbn/workflows-examples-cli",
+ "private": true,
+ "version": "1.0.0",
+ "license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0"
+}
diff --git a/src/platform/packages/shared/kbn-workflows-examples-cli/src/build_schema.ts b/src/platform/packages/shared/kbn-workflows-examples-cli/src/build_schema.ts
new file mode 100644
index 0000000000000..145ede224c368
--- /dev/null
+++ b/src/platform/packages/shared/kbn-workflows-examples-cli/src/build_schema.ts
@@ -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", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+import type { z } from '@kbn/zod/v4';
+import {
+ generateYamlSchemaFromConnectors,
+ getElasticsearchConnectors,
+ getKibanaConnectors,
+} from '@kbn/workflows';
+
+let cachedSchema: z.ZodType | undefined;
+
+export function buildPublicWorkflowSchema(): z.ZodType {
+ if (cachedSchema) return cachedSchema;
+ const connectors = [...getElasticsearchConnectors(), ...getKibanaConnectors()];
+ cachedSchema = generateYamlSchemaFromConnectors(connectors, [], true);
+ return cachedSchema;
+}
diff --git a/src/platform/packages/shared/kbn-workflows-examples-cli/src/discover_examples.ts b/src/platform/packages/shared/kbn-workflows-examples-cli/src/discover_examples.ts
new file mode 100644
index 0000000000000..3e896720cb7f9
--- /dev/null
+++ b/src/platform/packages/shared/kbn-workflows-examples-cli/src/discover_examples.ts
@@ -0,0 +1,42 @@
+/*
+ * 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+import { readdir } from 'fs/promises';
+import Path from 'path';
+
+const YAML_EXT = /\.ya?ml$/i;
+
+export interface DiscoveryOptions {
+ readonly recursive?: boolean;
+}
+
+export async function discoverExampleFiles(
+ rootDir: string,
+ options: DiscoveryOptions = {}
+): Promise {
+ const recursive = options.recursive ?? true;
+ const found: string[] = [];
+ await walk(rootDir, recursive, found);
+ return found.sort();
+}
+
+async function walk(dir: string, recursive: boolean, out: string[]): Promise {
+ const entries = await readdir(dir, { withFileTypes: true });
+ for (const entry of entries) {
+ if (entry.name.startsWith('.')) continue;
+ const absolute = Path.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ if (recursive) await walk(absolute, recursive, out);
+ continue;
+ }
+ if (entry.isFile() && YAML_EXT.test(entry.name)) {
+ out.push(absolute);
+ }
+ }
+}
diff --git a/src/platform/packages/shared/kbn-workflows-examples-cli/src/junit_report.ts b/src/platform/packages/shared/kbn-workflows-examples-cli/src/junit_report.ts
new file mode 100644
index 0000000000000..04cea24c43ff3
--- /dev/null
+++ b/src/platform/packages/shared/kbn-workflows-examples-cli/src/junit_report.ts
@@ -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", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+import type { ValidationOutcome } from './validate_example';
+
+export interface ExampleResult {
+ readonly name: string;
+ readonly file: string;
+ readonly durationSeconds: number;
+ readonly outcome: ValidationOutcome;
+}
+
+const CONTROL_CHAR_RE = /[\x00-\x1F]+/g;
+
+export function renderJUnitXml(results: readonly ExampleResult[]): string {
+ const failures = results.filter((r) => r.outcome.kind !== 'ok').length;
+ const totalSeconds = results.reduce((sum, r) => sum + r.durationSeconds, 0);
+
+ const cases = results.map(renderTestcase).join('');
+ return (
+ `\n` +
+ `\n` +
+ ` \n` +
+ cases +
+ ` \n` +
+ `\n`
+ );
+}
+
+function renderTestcase(result: ExampleResult): string {
+ const attrs =
+ `name="${escapeAttr(result.name)}" ` +
+ `classname="${escapeAttr(result.file)}" ` +
+ `time="${result.durationSeconds.toFixed(3)}"`;
+ if (result.outcome.kind === 'ok') {
+ return ` \n`;
+ }
+ const failure = renderFailureBody(result.outcome);
+ return ` \n${failure} \n`;
+}
+
+function renderFailureBody(outcome: Exclude): string {
+ switch (outcome.kind) {
+ case 'oversize':
+ return ` \n`;
+ case 'syntax-error':
+ return ` \n`;
+ case 'schema-error': {
+ const detail = outcome.issues.map((i) => `${i.path}: ${i.message}`).join('\n');
+ return ` ${escapeText(detail)}\n`;
+ }
+ case 'unexpected-error':
+ return ` \n`;
+ }
+}
+
+function escapeAttr(value: string): string {
+ return value
+ .replace(CONTROL_CHAR_RE, ' ')
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+}
+
+function escapeText(value: string): string {
+ return value
+ .replace(CONTROL_CHAR_RE, ' ')
+ .replace(/&/g, '&')
+ .replace(//g, '>');
+}
diff --git a/src/platform/packages/shared/kbn-workflows-examples-cli/src/run_validation.ts b/src/platform/packages/shared/kbn-workflows-examples-cli/src/run_validation.ts
new file mode 100644
index 0000000000000..69b3b4104eb70
--- /dev/null
+++ b/src/platform/packages/shared/kbn-workflows-examples-cli/src/run_validation.ts
@@ -0,0 +1,88 @@
+/*
+ * 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+import { readFile } from 'fs/promises';
+import Path from 'path';
+import type { ToolingLog } from '@kbn/tooling-log';
+import { buildPublicWorkflowSchema } from './build_schema';
+import { discoverExampleFiles } from './discover_examples';
+import type { ExampleResult } from './junit_report';
+import { validateExampleYaml, type ValidationOutcome } from './validate_example';
+
+export interface RunOptions {
+ readonly rootDir: string;
+ readonly log: ToolingLog;
+}
+
+export interface RunSummary {
+ readonly results: readonly ExampleResult[];
+ readonly passed: number;
+ readonly failed: number;
+}
+
+export async function runValidation({ rootDir, log }: RunOptions): Promise {
+ const files = await discoverExampleFiles(rootDir);
+ if (files.length === 0) {
+ log.warning(`No workflow YAML files found under ${rootDir}`);
+ return { results: [], passed: 0, failed: 0 };
+ }
+ log.info(`Validating ${files.length} example(s) under ${rootDir}`);
+
+ const schema = buildPublicWorkflowSchema();
+ const results: ExampleResult[] = [];
+ let passed = 0;
+ let failed = 0;
+
+ for (const file of files) {
+ const relative = Path.relative(rootDir, file);
+ const start = process.hrtime.bigint();
+ const outcome = await validateOne(file, schema);
+ const durationSeconds = Number(process.hrtime.bigint() - start) / 1e9;
+ results.push({ name: relative, file, durationSeconds, outcome });
+ if (outcome.kind === 'ok') {
+ passed += 1;
+ log.success(`✓ ${relative}`);
+ } else {
+ failed += 1;
+ log.error(`✗ ${relative}\n${describeOutcome(outcome)}`);
+ }
+ }
+
+ log.info(`Validated ${files.length} example(s): ${passed} passed, ${failed} failed`);
+ return { results, passed, failed };
+}
+
+async function validateOne(
+ file: string,
+ schema: ReturnType
+): Promise {
+ let yaml: string;
+ try {
+ yaml = await readFile(file, 'utf8');
+ } catch (error) {
+ return {
+ kind: 'unexpected-error',
+ message: `Failed to read file: ${(error as Error).message}`,
+ };
+ }
+ return validateExampleYaml(yaml, schema);
+}
+
+function describeOutcome(outcome: Exclude): string {
+ switch (outcome.kind) {
+ case 'oversize':
+ return ` oversize: ${outcome.bytes} > ${outcome.limit} bytes`;
+ case 'syntax-error':
+ return ` yaml syntax: ${outcome.message}`;
+ case 'schema-error':
+ return outcome.issues.map((i) => ` ${i.path}: ${i.message}`).join('\n');
+ case 'unexpected-error':
+ return ` unexpected: ${outcome.message}`;
+ }
+}
diff --git a/src/platform/packages/shared/kbn-workflows-examples-cli/src/validate_example.test.ts b/src/platform/packages/shared/kbn-workflows-examples-cli/src/validate_example.test.ts
new file mode 100644
index 0000000000000..e8ae9eeb88dba
--- /dev/null
+++ b/src/platform/packages/shared/kbn-workflows-examples-cli/src/validate_example.test.ts
@@ -0,0 +1,54 @@
+/*
+ * 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+import { readdirSync, readFileSync } from 'fs';
+import Path from 'path';
+import { buildPublicWorkflowSchema } from './build_schema';
+import { validateExampleYaml } from './validate_example';
+
+const EXAMPLES_DIR = Path.resolve(
+ __dirname,
+ '../../../../packages/shared/kbn-workflows/spec/examples'
+);
+
+describe('validateExampleYaml', () => {
+ const schema = buildPublicWorkflowSchema();
+
+ it('flags YAML syntax errors', () => {
+ const result = validateExampleYaml('name: "missing close-quote', schema);
+ expect(result.kind).toBe('syntax-error');
+ });
+
+ it('flags oversize YAML before parsing', () => {
+ const oversize = 'name: x\n' + 'a: '.repeat(2_000_000);
+ const result = validateExampleYaml(oversize, schema);
+ expect(result.kind).toBe('oversize');
+ });
+
+ it('flags schema errors with paths', () => {
+ const result = validateExampleYaml('enabled: not-a-boolean\nname: t\nsteps: []\n', schema);
+ expect(result.kind).toBe('schema-error');
+ if (result.kind === 'schema-error') {
+ expect(result.issues.length).toBeGreaterThan(0);
+ }
+ });
+
+ describe('bundled in-repo examples', () => {
+ const files = readdirSync(EXAMPLES_DIR).filter((f) => /\.ya?ml$/i.test(f));
+
+ it.each(files)('parses %s without crashing or producing unexpected errors', (filename) => {
+ const yaml = readFileSync(Path.join(EXAMPLES_DIR, filename), 'utf8');
+ const result = validateExampleYaml(yaml, schema);
+
+ expect(result.kind).not.toBe('syntax-error');
+ expect(result.kind).not.toBe('oversize');
+ expect(result.kind).not.toBe('unexpected-error');
+ });
+ });
+});
diff --git a/src/platform/packages/shared/kbn-workflows-examples-cli/src/validate_example.ts b/src/platform/packages/shared/kbn-workflows-examples-cli/src/validate_example.ts
new file mode 100644
index 0000000000000..081c28b4a5432
--- /dev/null
+++ b/src/platform/packages/shared/kbn-workflows-examples-cli/src/validate_example.ts
@@ -0,0 +1,57 @@
+/*
+ * 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+import type { z } from '@kbn/zod/v4';
+import { MAX_WORKFLOW_YAML_LENGTH } from '@kbn/workflows';
+import {
+ InvalidYamlSchemaError,
+ InvalidYamlSyntaxError,
+ parseWorkflowYamlToJSON,
+} from '@kbn/workflows-yaml';
+
+export type ValidationOutcome =
+ | { readonly kind: 'ok' }
+ | { readonly kind: 'oversize'; readonly bytes: number; readonly limit: number }
+ | { readonly kind: 'syntax-error'; readonly message: string }
+ | { readonly kind: 'schema-error'; readonly issues: readonly SchemaIssue[] }
+ | { readonly kind: 'unexpected-error'; readonly message: string };
+
+export interface SchemaIssue {
+ readonly path: string;
+ readonly message: string;
+}
+
+export function validateExampleYaml(yaml: string, schema: z.ZodType): ValidationOutcome {
+ const bytes = Buffer.byteLength(yaml, 'utf8');
+ if (bytes > MAX_WORKFLOW_YAML_LENGTH) {
+ return { kind: 'oversize', bytes, limit: MAX_WORKFLOW_YAML_LENGTH };
+ }
+
+ const result = parseWorkflowYamlToJSON(yaml, schema);
+
+ if (result.success) {
+ return { kind: 'ok' };
+ }
+
+ const { error } = result;
+ if (error instanceof InvalidYamlSyntaxError) {
+ return { kind: 'syntax-error', message: error.message };
+ }
+ if (error instanceof InvalidYamlSchemaError) {
+ const issues = (error.formattedZodError?.issues ?? []).map((issue) => ({
+ path: issue.path.map(String).join('.') || '',
+ message: issue.message,
+ }));
+ if (issues.length === 0) {
+ issues.push({ path: '', message: error.message });
+ }
+ return { kind: 'schema-error', issues };
+ }
+ return { kind: 'unexpected-error', message: error.message };
+}
diff --git a/src/platform/packages/shared/kbn-workflows-examples-cli/tsconfig.json b/src/platform/packages/shared/kbn-workflows-examples-cli/tsconfig.json
new file mode 100644
index 0000000000000..1d433658fea8c
--- /dev/null
+++ b/src/platform/packages/shared/kbn-workflows-examples-cli/tsconfig.json
@@ -0,0 +1,17 @@
+{
+ "extends": "@kbn/tsconfig-base/tsconfig.json",
+ "compilerOptions": {
+ "outDir": "target/types",
+ "types": ["jest", "node"]
+ },
+ "include": ["**/*.ts"],
+ "exclude": ["target/**/*"],
+ "kbn_references": [
+ "@kbn/dev-cli-runner",
+ "@kbn/dev-cli-errors",
+ "@kbn/workflows",
+ "@kbn/workflows-yaml",
+ "@kbn/tooling-log",
+ "@kbn/zod"
+ ]
+}
diff --git a/src/platform/packages/shared/kbn-workflows-yaml/common/yaml/parse_workflow_yaml_to_json.ts b/src/platform/packages/shared/kbn-workflows-yaml/common/yaml/parse_workflow_yaml_to_json.ts
index e73f9196183f6..13c0553858a7e 100644
--- a/src/platform/packages/shared/kbn-workflows-yaml/common/yaml/parse_workflow_yaml_to_json.ts
+++ b/src/platform/packages/shared/kbn-workflows-yaml/common/yaml/parse_workflow_yaml_to_json.ts
@@ -8,7 +8,7 @@
*/
import type { Document } from 'yaml';
-import type { ZodSafeParseResult, ZodType } from '@kbn/zod/v4';
+import type { output, ZodSafeParseSuccess, ZodType } from '@kbn/zod/v4';
import { ZodError } from '@kbn/zod/v4';
import { parseYamlToJSONWithoutValidation } from './parse_workflow_yaml_to_json_without_validation';
import { getYamlDocumentErrors } from './validate_yaml_document';
@@ -17,10 +17,9 @@ import { isDynamicValue, isLiquidTagValue, isVariableValue } from '../regex';
import type { ConnectorParamsSchemaResolver } from '../zod/enrich_error_message';
import { formatZodError } from '../zod/format_zod_error';
-export type ParseWorkflowYamlToJSONResult = (
- | ZodSafeParseResult
- | { success: false; error: Error }
-) & { document: Document };
+export type ParseWorkflowYamlToJSONResult =
+ | (ZodSafeParseSuccess
,
"displayName": "Share usage with Elastic",
+ "experimental": undefined,
"groupId": "Share usage with Elastic-group",
"id": "Usage collection",
"isCustom": true,
@@ -140,7 +141,9 @@ exports[`TelemetryManagementSectionComponent renders as expected 1`] = `
`;
-exports[`TelemetryManagementSectionComponent renders null because allowChangingOptInStatus is false 1`] = `
+exports[
+ `TelemetryManagementSectionComponent renders null because allowChangingOptInStatus is false 1`
+] = `
Date: Wed, 27 May 2026 16:34:06 +0200
Subject: [PATCH 037/193] [Security Solution] Instrument DetectionRulesClient
with change tracking (#270446)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
**Epic:** https://github.com/elastic/security-team/issues/12367
(internal)
**Resolves: #262502**
## Summary
Instruments Security Solution's `DetectionRulesClient` (DRC) and some
API routes directly with rule changes history functionality. It also
involved AF's `RulesClient` instrumentation streamlining to facilitate
the implementation.
## Details
Instrumentation boils down to passing the rule changes history context
information down the road via the chain Security Solution API endpoint
-> `DRC` -> `RulesClient` -> `@kbn/changes-history` package. There are
two parameters passing from DRC which are
- change tracking `action`
It should reflect domain specific change action. From that POV we have
methods where the action is clear (as minimum for now) like `delete` or
`bulkDelete` and methods where action depends on the upstream context
like `create` or `update`. For example Security solution uses
`RulesClient.create()` for prebuilt rules management introducing domain
specific actions like prebuilt rules installation upgrade and etc.
- `metadata`
Rule change tracking action related metadata.
- `metadata.bulkCount`
Performance optimization in the consumer code like chunking makes it
impossible to capture the real number of rules the bulk operation is
applied to. Consumer code may pass `bulkCount` when it's necessary.
Besides that `bulkCount` is supported by some non-bulk methods as they
don't have bulk counterparts. For non-bulk methods with bulk
counterparts `bulkCount` isn't exposed.
- `metadata.originalRuleSoId`
Rule's Saved Object identifier saved upon rule duplication.
### Changes
**Alerting plugin / `@kbn/alerting-types`**
- `RuleChangeTracking` made generic (`RuleChangeTracking`) so consumers can restrict the `action` field
to their own enum without wrapping the type.
- `create_rule` and `update_rule` wired to accept `changeTracking?:
RuleChangeTracking` and log the action via `logRuleChanges`.
- `bulk_delete_rules` and `bulk_edit_rules` accept `changeTracking?:
Omit` — the action is implicit for these
operations; `bulkCount` is provided by the caller to track totals across
processing chunks.
**Security Solution common**
- New `common/detection_engine/rule_management/rule_change_tracking.ts`
introduces `SecurityRuleChangeTrackingAction` enum (`ruleInstall`,
`ruleUpgrade`, `ruleDuplicate`, `ruleImport`, `ruleRevert`) and
`SecurityRuleChangeTracking` type alias.
**Detection Rules Client**
- `IDetectionRulesClient` interface: all mutating methods accept
optional `SecurityRuleChangeTracking`.
- Each method passes `changeTracking` through to the underlying
`RulesClient` call. Methods with a fixed semantic (`importRule` →
`ruleImport`, `upgradePrebuiltRule` → `ruleUpgrade`,
`revertPrebuiltRule` → `ruleRevert`) always inject the correct default
action, allowing callers to supply `bulkCount` without overriding the
action.
**Security Solution API routes / handlers**
- `PUT /api/detection_engine/rules/_import` — passes `changeTracking: {
action: ruleImport }` to the DRC.
- `PUT /internal/detection_engine/prebuilt_rules/installation/_perform`
— passes `changeTracking: { action: ruleInstall, bulkCount }`.
- `PUT /internal/detection_engine/prebuilt_rules/upgrade/_perform` —
passes `changeTracking: { action: ruleUpgrade, bulkCount }`.
- `PUT /internal/detection_engine/prebuilt_rules/revert` — passes
`changeTracking: { action: ruleRevert }`.
- `PUT /api/detection_engine/rules/prepackaged` (legacy) — passes
`changeTracking: { action: ruleInstall, bulkCount }`.
- Integration paths (endpoint security and promotion rule installation)
pass `changeTracking: { action: ruleInstall, bulkCount }`
programmatically.
## How to test
This change is a no-op without explicit opt-in. To exercise the new code
paths locally:
1. Set
[FLAGS.FEATURE_ENABLED](https://github.com/elastic/kibana/blob/main/x-pack/platform/packages/shared/kbn-change-history/src/constants.ts#L31)
to `true` in **@kbn/change-history** package
2.
3. Enable feature flags
```yaml
xpack.alerting.ruleChangeTracking.enabled: true
xpack.securitySolution.enableExperimental:
- ruleChangesHistoryEnabled
```
4. Make changes
3a. Install one or more prebuilt rules (`PUT
/internal/detection_engine/prebuilt_rules/installation/_perform`). Open
a freshly installed rule and verify the changes history shows an entry
with action `rule_install`.
3b. Upgrade one or more prebuilt rules (`PUT
/internal/detection_engine/prebuilt_rules/upgrade/_perform`). Verify
changes history shows `rule_upgrade`.
3c. Revert a customized prebuilt rule (`PUT
/internal/detection_engine/prebuilt_rules/revert`). Verify changes
history shows `rule_revert`.
3d. Import a rule ndjson file via **Manage Rules → Import** (`PUT
/api/detection_engine/rules/_import`). Verify changes history shows
`rule_import`.
5. Make a request to `GET /internal/detection_engine/rules/_history` to
explore the change history for each rule you changed above
```bash
curl -H 'Content-Type: application/json' -H 'kbn-xsrf: kibana' -H "elastic-api-version: 1" -H "x-elastic-internal-origin: true" -u elastic:changeme 'http://localhost:5601/kbn/internal/detection_engine/rules//history'
```
- Verify **FTR integration tests** added under
`x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_management/trial_license_complete_tier/change_tracking.ts`
pass.
### Identify risks
- Low risk: all `changeTracking` parameters are optional and additive.
Existing behavior is fully preserved when the parameter is omitted.
---
.../shared/kbn-alerting-types/rule_types.ts | 31 +-
.../methods/bulk_delete/bulk_delete_rules.ts | 23 +-
.../rule/methods/bulk_delete/types/index.ts | 9 +
.../rule/methods/bulk_edit/bulk_edit_rules.ts | 2 +-
.../types/bulk_edit_rules_options.ts | 2 +
.../bulk_edit_params/bulk_edit_rule_params.ts | 5 +-
.../types/bulk_edit_rule_params_options.ts | 2 +
.../methods/common_utils/log_rule_changes.ts | 26 +-
.../rule/methods/create/create_rule.ts | 7 +-
.../rule/methods/delete/delete_rule.test.ts | 122 +++++
.../rule/methods/delete/delete_rule.ts | 12 +
.../rule/methods/update/update_rule.ts | 9 +-
.../common/bulk_edit/bulk_edit_rules.ts | 10 +-
.../common/bulk_edit/bulk_edit_rules_occ.ts | 18 +-
.../server/rules_client/rules_client.ts | 4 +-
.../rule_management/rule_change_tracking.ts | 33 ++
.../legacy_create_prepackaged_rules.ts | 13 +-
.../perform_rule_installation_handler.ts | 7 +
.../perform_rule_upgrade_handler.ts | 7 +
...install_endpoint_security_prebuilt_rule.ts | 7 +-
.../integrations/install_promotion_rules.ts | 12 +
.../rule_objects/create_prebuilt_rules.ts | 9 +
.../rule_objects/revert_prebuilt_rules.ts | 7 +
.../rule_objects/upgrade_prebuilt_rules.ts | 3 +
.../api/rules/bulk_actions/route.ts | 17 +
.../api/rules/import_rules/route.ts | 5 +
...ction_rules_client.change_tracking.test.ts | 287 ++++++++++
...on_rules_client.create_custom_rule.test.ts | 4 +
..._rules_client.create_prebuilt_rule.test.ts | 8 +-
...detection_rules_client.import_rule.test.ts | 7 +
.../detection_rules_client.ts | 28 +-
...rules_client.upgrade_prebuilt_rule.test.ts | 11 +-
.../detection_rules_client_interface.ts | 10 +
.../methods/bulk_delete_rules.ts | 8 +-
.../methods/create_rule.ts | 8 +-
.../methods/import_rule.ts | 6 +
.../methods/import_rules.ts | 4 +
.../methods/patch_rule.ts | 5 +
.../update_rule_with_read_privileges.ts | 4 +
.../methods/revert_prebuilt_rule.ts | 9 +-
.../methods/update_rule.ts | 6 +
.../methods/upgrade_prebuilt_rule.ts | 10 +-
.../logic/import/import_rules.ts | 4 +
.../change_tracking.ts | 503 ++++++++++++++++++
.../trial_license_complete_tier/index.ts | 2 +-
.../rule_history.ts | 233 --------
46 files changed, 1268 insertions(+), 291 deletions(-)
create mode 100644 x-pack/solutions/security/plugins/security_solution/common/detection_engine/rule_management/rule_change_tracking.ts
create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.change_tracking.test.ts
create mode 100644 x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_management/trial_license_complete_tier/change_tracking.ts
delete mode 100644 x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_management/trial_license_complete_tier/rule_history.ts
diff --git a/src/platform/packages/shared/kbn-alerting-types/rule_types.ts b/src/platform/packages/shared/kbn-alerting-types/rule_types.ts
index a6bd6969ab196..8928665182848 100644
--- a/src/platform/packages/shared/kbn-alerting-types/rule_types.ts
+++ b/src/platform/packages/shared/kbn-alerting-types/rule_types.ts
@@ -31,7 +31,36 @@ export enum RuleChangeTrackingAction {
ruleDelete = 'rule_delete',
}
-export type ChangeTrackingAction = RuleChangeTrackingAction;
+export interface RuleChangeTrackingMetadata {
+ /**
+ * Bulk operation rules count. Due to chunking the actual total number of rules isn't available
+ * inside RulesClient. Passing this number will result in logging in change tracking item's metadata.
+ */
+ bulkCount?: number;
+ /**
+ * Rule duplication action original rule's Saved Object id
+ */
+ originalRuleSoId?: string;
+}
+
+/**
+ * Rule change tracking context.
+ * Contains information to be logged when change tracking functionality is active.
+ */
+export interface RuleChangeTracking {
+ /**
+ * Change action to be logged. RulesClient supports RuleChangeTrackingAction while
+ * consumers may use much wider actions spectrum. The action is indexed and searchable.
+ *
+ * Will use the default action for the current operation when omitted.
+ */
+ action?: ChangeAction;
+ /**
+ * Optional change tracking metadata to be logged. It contains extra information regarding the
+ * change. E.g. `metadata.bulkCount` says about how many rules were involved in a bulk operation.
+ */
+ metadata?: RuleChangeTrackingMetadata;
+}
export const ISO_WEEKDAYS = [1, 2, 3, 4, 5, 6, 7] as const;
export type IsoWeekday = (typeof ISO_WEEKDAYS)[number];
diff --git a/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/bulk_delete/bulk_delete_rules.ts b/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/bulk_delete/bulk_delete_rules.ts
index 0eb2f53d4dd0a..ab80115ceb4d6 100644
--- a/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/bulk_delete/bulk_delete_rules.ts
+++ b/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/bulk_delete/bulk_delete_rules.ts
@@ -10,6 +10,7 @@ import type { KueryNode } from '@kbn/es-query';
import { nodeBuilder } from '@kbn/es-query';
import type { SavedObject } from '@kbn/core/server';
import { withSpan } from '@kbn/apm-utils';
+import type { RuleChangeTracking } from '@kbn/alerting-types';
import { RuleChangeTrackingAction } from '@kbn/alerting-types';
import {
combineFiltersWithInternalRuleTypeFilter,
@@ -30,11 +31,7 @@ import {
buildKueryNodeFilter,
} from '../../../../rules_client/common';
import type { RulesClientContext } from '../../../../rules_client/types';
-import type {
- BulkOperationError,
- BulkDeleteRulesResult,
- BulkDeleteRulesRequestBody,
-} from './types';
+import type { BulkOperationError, BulkDeleteRulesResult, BulkDeleteRulesParams } from './types';
import { validateBulkDeleteRulesBody } from './validation';
import { bulkDeleteRulesSo } from '../../../../data/rule';
import { transformRuleAttributesToRuleDomain, transformRuleDomainToRule } from '../../transforms';
@@ -47,7 +44,7 @@ import { logRuleChanges } from '../common_utils/log_rule_changes';
export const bulkDeleteRules = async (
context: RulesClientContext,
- options: BulkDeleteRulesRequestBody
+ options: BulkDeleteRulesParams
): Promise> => {
try {
validateBulkDeleteRulesBody(options);
@@ -89,7 +86,12 @@ export const bulkDeleteRules = async (
action: 'DELETE',
logger: context.logger,
bulkOperation: (filterKueryNode: KueryNode | null) =>
- bulkDeleteWithOCC(context, { filter: filterKueryNode, totalNumOfRules: total }),
+ bulkDeleteWithOCC(context, {
+ filter: filterKueryNode,
+ changeTracking: {
+ metadata: { bulkCount: total, ...options.changeTracking?.metadata },
+ },
+ }),
filter: finalFilter,
})
);
@@ -154,7 +156,10 @@ export const bulkDeleteRules = async (
const bulkDeleteWithOCC = async (
context: RulesClientContext,
- { filter, totalNumOfRules }: { filter: KueryNode | null; totalNumOfRules: number }
+ {
+ filter,
+ changeTracking,
+ }: { filter: KueryNode | null; changeTracking: RuleChangeTracking }
) => {
const rulesFinder = await withSpan(
{
@@ -286,7 +291,7 @@ const bulkDeleteWithOCC = async (
changesContext: {
action: RuleChangeTrackingAction.ruleDelete,
timestamp: deletionTimestamp,
- metadata: { bulkCount: totalNumOfRules },
+ metadata: changeTracking?.metadata,
},
});
diff --git a/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/bulk_delete/types/index.ts b/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/bulk_delete/types/index.ts
index f78a41ac49b7c..edff2c9673624 100644
--- a/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/bulk_delete/types/index.ts
+++ b/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/bulk_delete/types/index.ts
@@ -4,6 +4,8 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
+
+import type { RuleChangeTracking } from '@kbn/alerting-types';
import type { TypeOf } from '@kbn/config-schema';
import type { bulkDeleteRulesRequestBodySchema } from '../schemas';
import type { SanitizedRule } from '../../../../../types';
@@ -20,6 +22,13 @@ export interface BulkOperationError {
export type BulkDeleteRulesRequestBody = TypeOf;
+export interface BulkDeleteRulesParams extends BulkDeleteRulesRequestBody {
+ // Rule deletion doesn't look to be ambiguous for consumers.
+ // Omitting "action" to avoid setting it accidentally to logically
+ // incompatible value.
+ changeTracking?: Omit;
+}
+
export interface BulkDeleteRulesResult {
rules: Array>;
errors: BulkOperationError[];
diff --git a/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/bulk_edit/bulk_edit_rules.ts b/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/bulk_edit/bulk_edit_rules.ts
index fc1401789f46b..163d3c6916c89 100644
--- a/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/bulk_edit/bulk_edit_rules.ts
+++ b/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/bulk_edit/bulk_edit_rules.ts
@@ -67,7 +67,7 @@ export async function bulkEditRules(
auditAction,
requiredAuthOperation,
shouldInvalidateApiKeys,
- changeTrackingAction: RuleChangeTrackingAction.ruleUpdate,
+ changeTracking: { action: RuleChangeTrackingAction.ruleUpdate, ...options.changeTracking },
shouldValidateSchedule: options.operations.some((operation) => operation.field === 'schedule'),
updateFn: (opts: UpdateOperationOpts) =>
updateRuleAttributesAndParamsInMemory({
diff --git a/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/bulk_edit/types/bulk_edit_rules_options.ts b/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/bulk_edit/types/bulk_edit_rules_options.ts
index 5a77632237cfb..09fa23b96a302 100644
--- a/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/bulk_edit/types/bulk_edit_rules_options.ts
+++ b/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/bulk_edit/types/bulk_edit_rules_options.ts
@@ -7,6 +7,7 @@
import type { TypeOf } from '@kbn/config-schema';
import type { KueryNode } from '@kbn/es-query';
+import type { RuleChangeTracking } from '@kbn/alerting-types';
import type { ParamsModifier, ShouldIncrementRevision } from '../../../../../rules_client/common';
import type {
bulkEditRuleSnoozeScheduleSchema,
@@ -30,6 +31,7 @@ export interface BulkEditOptionsCommon {
ignoreInternalRuleTypes?: boolean;
paramsModifier?: ParamsModifier;
shouldIncrementRevision?: ShouldIncrementRevision;
+ changeTracking?: RuleChangeTracking;
}
export type BulkEditOptionsFilter = BulkEditOptionsCommon & {
diff --git a/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/bulk_edit_params/bulk_edit_rule_params.ts b/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/bulk_edit_params/bulk_edit_rule_params.ts
index b974b1df68c08..23e53c72a729c 100644
--- a/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/bulk_edit_params/bulk_edit_rule_params.ts
+++ b/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/bulk_edit_params/bulk_edit_rule_params.ts
@@ -57,7 +57,10 @@ export async function bulkEditRuleParamsWithReadAuth(
auditAction,
requiredAuthOperation,
shouldInvalidateApiKeys,
- changeTrackingAction: RuleChangeTrackingAction.ruleUpdate,
+ changeTracking: {
+ action: RuleChangeTrackingAction.ruleUpdate,
+ ...options.changeTracking,
+ },
updateFn: (opts: UpdateOperationOpts) =>
updateRuleParamsInMemory({
...opts,
diff --git a/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/bulk_edit_params/types/bulk_edit_rule_params_options.ts b/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/bulk_edit_params/types/bulk_edit_rule_params_options.ts
index 5fdef01911001..028a813ba765a 100644
--- a/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/bulk_edit_params/types/bulk_edit_rule_params_options.ts
+++ b/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/bulk_edit_params/types/bulk_edit_rule_params_options.ts
@@ -7,6 +7,7 @@
import type { TypeOf } from '@kbn/config-schema';
import type { KueryNode } from '@kbn/es-query';
+import type { RuleChangeTracking } from '@kbn/alerting-types';
import type { RuleParams } from '../../../types';
import type { ParamsModifier, ShouldIncrementRevision } from '../../../../../rules_client/common';
import type {
@@ -26,4 +27,5 @@ export interface BulkEditRuleParamsOptions {
operations: BulkEditParamsOperation[];
paramsModifier?: ParamsModifier;
shouldIncrementRevision?: ShouldIncrementRevision;
+ changeTracking?: RuleChangeTracking;
}
diff --git a/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/common_utils/log_rule_changes.ts b/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/common_utils/log_rule_changes.ts
index 724392522033d..f4acd4d250208 100644
--- a/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/common_utils/log_rule_changes.ts
+++ b/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/common_utils/log_rule_changes.ts
@@ -5,6 +5,9 @@
* 2.0.
*/
+import { every, isUndefined } from 'lodash';
+import type { LogChangeHistoryOptions } from '@kbn/change-history';
+import type { RuleChangeTrackingMetadata } from '@kbn/alerting-types';
import type { Logger, SavedObject } from '@kbn/core/server';
import type { RuleChange } from '../../../../rules_client/lib/change_tracking';
import type { RawRule, RuleTypeRegistry } from '../../../../types';
@@ -32,16 +35,7 @@ interface LogRuleChanges {
/**
* Change metadata object to be written to the each change history item
*/
- metadata?: {
- /**
- * Original number of rules affected by the bulk action.
- *
- * Driving code should provide this number for bulk actions.
- * Due to OCC we can't capture this number deeper in the call stack.
- *
- * Default: ruleSOs.length when not provided
- */ bulkCount?: number;
- } & Record;
+ metadata?: RuleChangeTrackingMetadata;
};
}
@@ -63,6 +57,12 @@ export async function logRuleChanges({
const ruleType = getRuleType(ruleTypeRegistry, ruleSO.attributes.alertTypeId, logger);
+ // "ruleType.trackChanges" is activated at Alerting plugin's "plugin.ts".
+ //
+ // The activation is gated by the feature flag "xpack.alerting.ruleChangeTracking.enabled".
+ // On top of that "xpack.alerting.ruleChangeTracking.scope" controls what solution rule
+ // types will be activated, e.g. "security" or "observability".
+ //
if (!ruleType?.trackChanges) {
continue;
}
@@ -84,10 +84,14 @@ export async function logRuleChanges({
}
try {
+ const data: LogChangeHistoryOptions['data'] = every(metadata, isUndefined)
+ ? undefined
+ : { metadata: metadata as Record | undefined };
+
await changeTrackingService.logBulk(changes, {
action,
spaceId,
- ...(metadata ? { data: { metadata } } : {}),
+ data,
});
} catch (e) {
logger.warn(`Unable to log bulk rule changes for action "${action}": ${e}`);
diff --git a/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/create/create_rule.ts b/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/create/create_rule.ts
index fc5a60ca48ed8..4edfddc52a9a3 100644
--- a/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/create/create_rule.ts
+++ b/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/create/create_rule.ts
@@ -9,6 +9,7 @@ import Boom from '@hapi/boom';
import type { SavedObject } from '@kbn/core/server';
import { SavedObjectsUtils } from '@kbn/core/server';
import { withSpan } from '@kbn/apm-utils';
+import type { RuleChangeTracking } from '@kbn/alerting-types';
import { RuleChangeTrackingAction } from '@kbn/alerting-types';
import { validateAndAuthorizeSystemActions } from '../../../../lib/validate_authorize_system_actions';
import { RULE_SAVED_OBJECT_TYPE } from '../../../../saved_objects';
@@ -55,6 +56,7 @@ export interface CreateRuleOptions {
export interface CreateRuleParams {
data: CreateRuleData;
options?: CreateRuleOptions;
+ changeTracking?: RuleChangeTracking;
allowMissingConnectorSecrets?: boolean;
}
@@ -63,7 +65,7 @@ export async function createRule(
createParams: CreateRuleParams
// TODO (http-versioning): This should be of type Rule, change this when all rule types are fixed
): Promise> {
- const { data: initialData, options, allowMissingConnectorSecrets } = createParams;
+ const { data: initialData, options, changeTracking, allowMissingConnectorSecrets } = createParams;
const actionsClient = await context.getActionsClient();
@@ -257,8 +259,9 @@ export async function createRule(
ruleSOs: [createdRuleSavedObject],
rulesClientContext: context,
changesContext: {
- action: RuleChangeTrackingAction.ruleCreate,
+ action: changeTracking?.action ?? RuleChangeTrackingAction.ruleCreate,
timestamp: createTime,
+ metadata: changeTracking?.metadata,
},
});
diff --git a/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/delete/delete_rule.test.ts b/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/delete/delete_rule.test.ts
index cd04422c96106..a2c631db6213a 100644
--- a/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/delete/delete_rule.test.ts
+++ b/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/delete/delete_rule.test.ts
@@ -23,6 +23,7 @@ import type { AlertingAuthorization } from '../../../../authorization/alerting_a
import type { ActionsAuthorization } from '@kbn/actions-plugin/server';
import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks';
import { getBeforeSetup } from '../../../../rules_client/tests/lib';
+import { RecoveredActionGroup } from '../../../../../common';
import { bulkMarkApiKeysForInvalidation } from '../../../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation';
import { ConnectorAdapterRegistry } from '../../../../connector_adapters/connector_adapter_registry';
import { RULE_SAVED_OBJECT_TYPE } from '../../../../saved_objects';
@@ -404,4 +405,125 @@ describe('delete()', () => {
);
});
});
+
+ describe('change tracking', () => {
+ const createChangeTrackingService = () => ({
+ log: jest.fn().mockResolvedValue(undefined),
+ logBulk: jest.fn().mockResolvedValue(undefined),
+ getHistory: jest.fn().mockResolvedValue({ items: [], total: 0 }),
+ });
+
+ const setRuleType = (overrides: { trackChanges?: boolean } = {}) => {
+ ruleTypeRegistry.get.mockReturnValue({
+ id: 'myType',
+ name: 'Test',
+ actionGroups: [{ id: 'default', name: 'Default' }],
+ recoveryActionGroup: RecoveredActionGroup,
+ defaultActionGroupId: 'default',
+ minimumLicenseRequired: 'basic',
+ isExportable: true,
+ async executor() {
+ return { state: {} };
+ },
+ category: 'test',
+ producer: 'alerts',
+ solution: 'stack' as const,
+ validate: { params: { validate: (params) => params } },
+ validLegacyConsumers: [],
+ trackChanges: true,
+ ...overrides,
+ });
+ };
+
+ test('logs the change when the rule is deleted', async () => {
+ const changeTrackingService = createChangeTrackingService();
+ const trackingClient = new RulesClient({ ...rulesClientParams, changeTrackingService });
+ setRuleType();
+
+ await trackingClient.delete({ id: '1' });
+
+ expect(changeTrackingService.logBulk).toHaveBeenCalledTimes(1);
+ expect(changeTrackingService.logBulk).toHaveBeenCalledWith(
+ [expect.objectContaining({ objectId: '1', module: 'stack' })],
+ {
+ action: 'rule_delete',
+ spaceId: 'default',
+ }
+ );
+ });
+
+ test('captures the pre-deletion attributes and references of the rule', async () => {
+ const changeTrackingService = createChangeTrackingService();
+ const trackingClient = new RulesClient({ ...rulesClientParams, changeTrackingService });
+ setRuleType();
+
+ await trackingClient.delete({ id: '1' });
+
+ expect(changeTrackingService.logBulk).toHaveBeenCalledWith(
+ [
+ {
+ timestamp: expect.any(String),
+ objectId: '1',
+ objectType: RULE_SAVED_OBJECT_TYPE,
+ module: 'stack',
+ snapshot: {
+ attributes: existingDecryptedAlert.attributes,
+ references: existingDecryptedAlert.references,
+ },
+ },
+ ],
+ expect.any(Object)
+ );
+ });
+
+ test('stamps the change with the time the delete flow began', async () => {
+ const changeTrackingService = createChangeTrackingService();
+ const trackingClient = new RulesClient({ ...rulesClientParams, changeTrackingService });
+ setRuleType();
+
+ const startTimeMs = Date.parse('2030-06-01T08:00:00.000Z');
+ const dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(startTimeMs);
+
+ try {
+ await trackingClient.delete({ id: '1' });
+
+ expect(changeTrackingService.logBulk).toHaveBeenCalledTimes(1);
+ const [changes] = changeTrackingService.logBulk.mock.calls[0];
+ expect(changes).toHaveLength(1);
+ expect(changes[0].timestamp).toBe('2030-06-01T08:00:00.000Z');
+ } finally {
+ dateNowSpy.mockRestore();
+ }
+ });
+
+ test('does not log when the rule type opts out of tracking', async () => {
+ const changeTrackingService = createChangeTrackingService();
+ const trackingClient = new RulesClient({ ...rulesClientParams, changeTrackingService });
+ setRuleType({ trackChanges: false });
+
+ await trackingClient.delete({ id: '1' });
+
+ expect(changeTrackingService.logBulk).not.toHaveBeenCalled();
+ });
+
+ test('does not log when no change tracking service is configured', async () => {
+ setRuleType();
+
+ await rulesClient.delete({ id: '1' });
+
+ expect(unsecuredSavedObjectsClient.delete).toHaveBeenCalled();
+ });
+
+ test('rule deletion succeeds even if change tracking throws', async () => {
+ const changeTrackingService = createChangeTrackingService();
+ changeTrackingService.logBulk.mockRejectedValueOnce(new Error('boom'));
+ const trackingClient = new RulesClient({ ...rulesClientParams, changeTrackingService });
+ setRuleType();
+
+ await expect(trackingClient.delete({ id: '1' })).resolves.toBeDefined();
+ expect(rulesClientParams.logger.warn).toHaveBeenCalledWith(
+ expect.stringContaining('Unable to log bulk rule changes for action "rule_delete"')
+ );
+ });
+ });
});
diff --git a/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/delete/delete_rule.ts b/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/delete/delete_rule.ts
index ebfefa0fb58df..d4a56e096467f 100644
--- a/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/delete/delete_rule.ts
+++ b/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/delete/delete_rule.ts
@@ -8,6 +8,7 @@
import Boom from '@hapi/boom';
import type { SavedObject } from '@kbn/core/server';
import { AlertConsumers } from '@kbn/rule-data-utils';
+import { RuleChangeTrackingAction } from '@kbn/alerting-types';
import type { RawRule } from '../../../../types';
import { WriteOperations, AlertingAuthorizationEntity } from '../../../../authorization';
import { retryIfConflicts } from '../../../../lib/retry_if_conflicts';
@@ -20,6 +21,7 @@ import type { DeleteRuleParams } from './types';
import { deleteRuleParamsSchema } from './schemas';
import { deleteRuleSo, getDecryptedRuleSo, getRuleSo } from '../../../../data/rule';
import { softDeleteGaps } from '../../../../lib/rule_gaps/soft_delete/soft_delete_gaps';
+import { logRuleChanges } from '../common_utils/log_rule_changes';
export async function deleteRule(context: RulesClientContext, params: DeleteRuleParams) {
try {
@@ -128,11 +130,21 @@ async function deleteRuleWithOCC(context: RulesClientContext, { id }: { id: stri
context.logger.error(`delete(): Failed to soft delete gaps for rule ${id}: ${error.message}`);
}
+ const deleteTime = Date.now();
const removeResult = await deleteRuleSo({
savedObjectsClient: context.unsecuredSavedObjectsClient,
id,
});
+ await logRuleChanges({
+ ruleSOs: [rule],
+ rulesClientContext: context,
+ changesContext: {
+ action: RuleChangeTrackingAction.ruleDelete,
+ timestamp: deleteTime,
+ },
+ });
+
await Promise.all([
taskIdToRemove ? context.taskManager.removeIfExists(taskIdToRemove) : null,
context.backfillClient.deleteBackfillForRules({
diff --git a/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/update/update_rule.ts b/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/update/update_rule.ts
index 6dcd025675f01..f4987513e755a 100644
--- a/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/update/update_rule.ts
+++ b/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/update/update_rule.ts
@@ -8,6 +8,7 @@
import Boom from '@hapi/boom';
import { isEqual, omit } from 'lodash';
import type { SavedObject } from '@kbn/core/server';
+import type { RuleChangeTracking } from '@kbn/alerting-types';
import { RuleChangeTrackingAction } from '@kbn/alerting-types';
import type { SanitizedRule, RawRule } from '../../../../types';
import { validateRuleTypeParams, getRuleNotifyWhenType } from '../../../../lib';
@@ -55,6 +56,7 @@ export interface UpdateRuleParams {
data: UpdateRuleData;
allowMissingConnectorSecrets?: boolean;
shouldIncrementRevision?: ShouldIncrementRevision;
+ changeTracking?: RuleChangeTracking;
}
export async function updateRule(
@@ -78,6 +80,7 @@ async function updateWithOCC(
allowMissingConnectorSecrets,
id,
shouldIncrementRevision = () => true,
+ changeTracking,
} = updateParams;
// Validate update rule data schema
@@ -212,6 +215,7 @@ async function updateWithOCC(
originalRuleSavedObject,
shouldIncrementRevision,
isSystemAction: (connectorId: string) => actionsClient.isSystemAction(connectorId),
+ changeTracking,
});
// Log warning if schedule interval is less than the minimum but we're not enforcing it
@@ -276,6 +280,7 @@ async function updateRuleAttributes({
originalRuleSavedObject,
shouldIncrementRevision,
isSystemAction,
+ changeTracking,
}: {
context: RulesClientContext;
updateRuleData: UpdateRuleData;
@@ -283,6 +288,7 @@ async function updateRuleAttributes({
validatedRuleTypeParams: Params;
shouldIncrementRevision: (params?: Params) => boolean;
isSystemAction: (connectorId: string) => boolean;
+ changeTracking?: RuleChangeTracking;
// TODO (http-versioning): This should be of type Rule, change this when all rule types are fixed
}): Promise> {
await bulkMigrateLegacyActions({ context, rules: [originalRuleSavedObject] });
@@ -384,8 +390,9 @@ async function updateRuleAttributes({
ruleSOs: [updatedRuleSavedObject],
rulesClientContext: context,
changesContext: {
- action: RuleChangeTrackingAction.ruleUpdate,
+ action: changeTracking?.action ?? RuleChangeTrackingAction.ruleUpdate,
timestamp: updateRuleTimestamp,
+ metadata: changeTracking?.metadata,
},
});
} catch (e) {
diff --git a/x-pack/platform/plugins/shared/alerting/server/rules_client/common/bulk_edit/bulk_edit_rules.ts b/x-pack/platform/plugins/shared/alerting/server/rules_client/common/bulk_edit/bulk_edit_rules.ts
index df73b37502af7..1be6a1272f8e2 100644
--- a/x-pack/platform/plugins/shared/alerting/server/rules_client/common/bulk_edit/bulk_edit_rules.ts
+++ b/x-pack/platform/plugins/shared/alerting/server/rules_client/common/bulk_edit/bulk_edit_rules.ts
@@ -7,7 +7,7 @@
import Boom from '@hapi/boom';
import { nodeBuilder, type KueryNode } from '@kbn/es-query';
-import type { ChangeTrackingAction } from '@kbn/alerting-types';
+import type { RuleChangeTracking } from '@kbn/alerting-types';
import type { RuleParams } from '../../../application/rule/types';
import type { RulesClientContext } from '../../types';
import { type RuleAuditAction } from '../audit_events';
@@ -49,7 +49,7 @@ export interface BulkEditOptions {
paramsModifier?: ParamsModifier;
shouldIncrementRevision?: ShouldIncrementRevision;
ignoreInternalRuleTypes?: boolean;
- changeTrackingAction?: ChangeTrackingAction;
+ changeTracking?: RuleChangeTracking;
}
export async function bulkEditRules(
@@ -111,8 +111,10 @@ export async function bulkEditRules(
updateFn: options.updateFn,
paramsModifier: options.paramsModifier,
shouldIncrementRevision: options.shouldIncrementRevision,
- changeTrackingAction: options.changeTrackingAction,
- totalNumOfRules: total,
+ changeTracking: {
+ ...options.changeTracking,
+ metadata: { bulkCount: total, ...options.changeTracking?.metadata },
+ },
}),
finalFilter
);
diff --git a/x-pack/platform/plugins/shared/alerting/server/rules_client/common/bulk_edit/bulk_edit_rules_occ.ts b/x-pack/platform/plugins/shared/alerting/server/rules_client/common/bulk_edit/bulk_edit_rules_occ.ts
index b8c80d8015f14..2567e5897da41 100644
--- a/x-pack/platform/plugins/shared/alerting/server/rules_client/common/bulk_edit/bulk_edit_rules_occ.ts
+++ b/x-pack/platform/plugins/shared/alerting/server/rules_client/common/bulk_edit/bulk_edit_rules_occ.ts
@@ -13,7 +13,7 @@ import type {
SavedObjectsBulkUpdateObject,
SavedObjectsFindResult,
} from '@kbn/core/server';
-import type { ChangeTrackingAction } from '@kbn/alerting-types';
+import type { RuleChangeTracking } from '@kbn/alerting-types';
import { RuleChangeTrackingAction } from '@kbn/alerting-types';
import { logRuleChanges } from '../../../application/rule/methods/common_utils/log_rule_changes';
import type { RuleParams } from '../../../application/rule/types';
@@ -45,8 +45,7 @@ export interface BulkEditOccOptions {
shouldInvalidateApiKeys: boolean;
paramsModifier?: ParamsModifier;
shouldIncrementRevision?: ShouldIncrementRevision;
- changeTrackingAction?: ChangeTrackingAction;
- totalNumOfRules?: number;
+ changeTracking?: RuleChangeTracking;
}
const isValidInterval = (interval: string | undefined): interval is string => {
@@ -164,8 +163,7 @@ export async function bulkEditRulesOcc(
rules,
apiKeysMap,
shouldInvalidateApiKeys: options.shouldInvalidateApiKeys,
- changeTrackingAction: options.changeTrackingAction,
- totalNumOfRules: options.totalNumOfRules,
+ changeTracking: options.changeTracking,
});
return {
@@ -182,15 +180,13 @@ async function saveBulkUpdatedRules({
rules,
apiKeysMap,
shouldInvalidateApiKeys,
- changeTrackingAction,
- totalNumOfRules,
+ changeTracking,
}: {
context: RulesClientContext;
rules: Array>;
shouldInvalidateApiKeys: boolean;
apiKeysMap: ApiKeysMap;
- changeTrackingAction?: ChangeTrackingAction;
- totalNumOfRules?: number;
+ changeTracking?: RuleChangeTracking;
}) {
const apiKeysToInvalidate: string[] = [];
let result: SavedObjectsBulkResponse;
@@ -210,9 +206,9 @@ async function saveBulkUpdatedRules({
ruleSOs: result.saved_objects,
rulesClientContext: context,
changesContext: {
- action: changeTrackingAction ?? RuleChangeTrackingAction.ruleUpdate,
+ action: changeTracking?.action ?? RuleChangeTrackingAction.ruleUpdate,
timestamp: bulkEditRulesTimestamp,
- metadata: totalNumOfRules ? { bulkCount: totalNumOfRules } : undefined,
+ metadata: changeTracking?.metadata,
},
});
} catch (e) {
diff --git a/x-pack/platform/plugins/shared/alerting/server/rules_client/rules_client.ts b/x-pack/platform/plugins/shared/alerting/server/rules_client/rules_client.ts
index 0466fd785b648..19714f7760f1a 100644
--- a/x-pack/platform/plugins/shared/alerting/server/rules_client/rules_client.ts
+++ b/x-pack/platform/plugins/shared/alerting/server/rules_client/rules_client.ts
@@ -52,7 +52,7 @@ import type { AggregateParams } from '../application/rule/methods/aggregate/type
import { aggregateRules } from '../application/rule/methods/aggregate';
import type { DeleteRuleParams } from '../application/rule/methods/delete';
import { deleteRule } from '../application/rule/methods/delete';
-import type { BulkDeleteRulesRequestBody } from '../application/rule/methods/bulk_delete';
+import type { BulkDeleteRulesParams } from '../application/rule/methods/bulk_delete/types';
import { bulkDeleteRules } from '../application/rule/methods/bulk_delete';
import type { BulkDisableRulesRequestBody } from '../application/rule/methods/bulk_disable';
import { bulkDisableRules } from '../application/rule/methods/bulk_disable';
@@ -193,7 +193,7 @@ export class RulesClient {
public bulkGetRules = (params: BulkGetRulesParams) =>
bulkGetRules(this.context, params);
- public bulkDeleteRules = (options: BulkDeleteRulesRequestBody) =>
+ public bulkDeleteRules = (options: BulkDeleteRulesParams) =>
bulkDeleteRules(this.context, options);
public bulkEdit = (options: BulkEditOptions) =>
bulkEditRules(this.context, options);
diff --git a/x-pack/solutions/security/plugins/security_solution/common/detection_engine/rule_management/rule_change_tracking.ts b/x-pack/solutions/security/plugins/security_solution/common/detection_engine/rule_management/rule_change_tracking.ts
new file mode 100644
index 0000000000000..ed2b1189023ff
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/common/detection_engine/rule_management/rule_change_tracking.ts
@@ -0,0 +1,33 @@
+/*
+ * 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 { RuleChangeTracking } from '@kbn/alerting-types';
+
+/**
+ * Security solution domain specific rule change
+ * tracking actions to cover operations like
+ * prebuilt rules installation and upgrade.
+ */
+export enum SecurityRuleChangeTrackingAction {
+ ruleInstall = 'rule_install',
+ ruleUpgrade = 'rule_upgrade',
+ ruleDuplicate = 'rule_duplicate',
+ ruleImport = 'rule_import',
+ ruleRevert = 'rule_revert',
+}
+
+/**
+ * Security Solution specific rule change tracking type.
+ * Restricts action to SecurityRuleChangeTrackingAction values, representing
+ * domain-specific operations like rule install, upgrade, import, etc.
+ * Consumers should pass a non-default action to distinguish domain specific
+ * operations from generic alerting change tracking actions, see RuleChangeTrackingAction.
+ * Omit the action field to let the underlying RulesClient apply the default action for the operation.
+ */
+export type SecurityRuleChangeTracking<
+ ChangeAction extends string = SecurityRuleChangeTrackingAction
+> = RuleChangeTracking;
diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/install_prebuilt_rules_and_timelines/legacy_create_prepackaged_rules.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/install_prebuilt_rules_and_timelines/legacy_create_prepackaged_rules.ts
index 0f69776b2786b..6cc6f414f22c9 100644
--- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/install_prebuilt_rules_and_timelines/legacy_create_prepackaged_rules.ts
+++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/install_prebuilt_rules_and_timelines/legacy_create_prepackaged_rules.ts
@@ -72,9 +72,15 @@ export const legacyCreatePrepackagedRules = async (
mlAuthz
);
+ const installChangeTracking = {
+ metadata: {
+ bulkCount: rulesToInstall.length,
+ },
+ };
const ruleCreationResult = await createPrebuiltRules(
detectionRulesClient,
rulesToInstall,
+ installChangeTracking,
logger
);
@@ -84,7 +90,12 @@ export const legacyCreatePrepackagedRules = async (
const { result: timelinesResult } = await performTimelinesInstallation(context);
- await upgradePrebuiltRules(detectionRulesClient, rulesToUpdate, logger);
+ const upgradeChangeTracking = {
+ metadata: {
+ bulkCount: rulesToUpdate.length,
+ },
+ };
+ await upgradePrebuiltRules(detectionRulesClient, rulesToUpdate, upgradeChangeTracking, logger);
return {
rules_installed: rulesToInstall.length,
diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_installation/perform_rule_installation_handler.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_installation/perform_rule_installation_handler.ts
index 57218d0c3f2bb..36ece0b2f841b 100644
--- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_installation/perform_rule_installation_handler.ts
+++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_installation/perform_rule_installation_handler.ts
@@ -110,6 +110,12 @@ export const performRuleInstallationHandler = async (
ruleInstallQueue.push(...(await excludeLicenseRestrictedRules(allInstallableRules, mlAuthz)));
}
+ const changeTracking = {
+ metadata: {
+ bulkCount: ruleInstallQueue.length,
+ },
+ };
+
const BATCH_SIZE = 100;
while (ruleInstallQueue.length > 0) {
const rulesToInstall = ruleInstallQueue.splice(0, BATCH_SIZE);
@@ -118,6 +124,7 @@ export const performRuleInstallationHandler = async (
const { results, errors } = await createPrebuiltRules(
detectionRulesClient,
ruleAssets,
+ changeTracking,
logger
);
diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/perform_rule_upgrade_handler.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/perform_rule_upgrade_handler.ts
index 71fd505553f46..7e5f7b1addd8a 100644
--- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/perform_rule_upgrade_handler.ts
+++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/perform_rule_upgrade_handler.ts
@@ -221,9 +221,16 @@ export const performRuleUpgradeHandler = async (
}))
);
} else {
+ const changeTracking = {
+ metadata: {
+ bulkCount: modifiedPrebuiltRuleAssets.length,
+ },
+ };
+
const { results: upgradeResults, errors: installationErrors } = await upgradePrebuiltRules(
detectionRulesClient,
modifiedPrebuiltRuleAssets,
+ changeTracking,
logger
);
ruleErrors.push(...installationErrors);
diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/integrations/install_endpoint_security_prebuilt_rule.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/integrations/install_endpoint_security_prebuilt_rule.ts
index cae073192fef3..e9130b05bc162 100644
--- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/integrations/install_endpoint_security_prebuilt_rule.ts
+++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/integrations/install_endpoint_security_prebuilt_rule.ts
@@ -82,7 +82,12 @@ export const installEndpointSecurityPrebuiltRule = async ({
return;
}
const ruleAssetsToInstall = await ruleAssetsClient.fetchAssetsByVersion(latestRuleVersion);
- await createPrebuiltRules(detectionRulesClient, ruleAssetsToInstall, logger);
+ const changeTracking = {
+ metadata: {
+ bulkCount: ruleAssetsToInstall.length,
+ },
+ };
+ await createPrebuiltRules(detectionRulesClient, ruleAssetsToInstall, changeTracking, logger);
} catch (err) {
logger.error(
`Unable to create Endpoint Security rule automatically (${err.statusCode}): ${err.message}`
diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/integrations/install_promotion_rules.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/integrations/install_promotion_rules.ts
index 2f22afe580e6b..b35eee340d583 100644
--- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/integrations/install_promotion_rules.ts
+++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/integrations/install_promotion_rules.ts
@@ -95,12 +95,18 @@ export async function installPromotionRules({
const promotionRulesToInstall = latestPromotionRules.filter(({ rule_id: ruleId }) => {
return !installedRuleVersionsMap.has(ruleId);
});
+ const installChangeTracking = {
+ metadata: {
+ bulkCount: promotionRulesToInstall.length,
+ },
+ };
const { results: installationResults, errors: installationErrors } = await createPrebuiltRules(
detectionRulesClient,
promotionRulesToInstall.map((asset) => ({
...asset,
enabled: true,
})),
+ installChangeTracking,
logger
);
@@ -108,9 +114,15 @@ export async function installPromotionRules({
const installedVersion = installedRuleVersionsMap.get(ruleId);
return installedVersion && installedVersion.version < version;
});
+ const upgradeChangeTracking = {
+ metadata: {
+ bulkCount: promotionRulesToUpgrade.length,
+ },
+ };
const { results: upgradeResults, errors: upgradeErrors } = await upgradePrebuiltRules(
detectionRulesClient,
promotionRulesToUpgrade,
+ upgradeChangeTracking,
logger
);
diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/rule_objects/create_prebuilt_rules.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/rule_objects/create_prebuilt_rules.ts
index 2541355695065..250f40cadb728 100644
--- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/rule_objects/create_prebuilt_rules.ts
+++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/rule_objects/create_prebuilt_rules.ts
@@ -6,6 +6,7 @@
*/
import type { Logger } from '@kbn/core/server';
+import type { SecurityRuleChangeTracking } from '../../../../../../common/detection_engine/rule_management/rule_change_tracking';
import { MAX_RULES_TO_UPDATE_IN_PARALLEL } from '../../../../../../common/constants';
import { initPromisePool } from '../../../../../utils/promise_pool';
import { withSecuritySpan } from '../../../../../utils/with_security_span';
@@ -15,6 +16,7 @@ import type { IDetectionRulesClient } from '../../../rule_management/logic/detec
export const createPrebuiltRules = (
detectionRulesClient: IDetectionRulesClient,
rules: PrebuiltRuleAsset[],
+ changeTracking?: SecurityRuleChangeTracking,
logger?: Logger
) => {
return withSecuritySpan('createPrebuiltRules', async () => {
@@ -27,6 +29,13 @@ export const createPrebuiltRules = (
executor: async (rule) => {
return detectionRulesClient.createPrebuiltRule({
params: rule,
+ changeTracking: {
+ ...changeTracking,
+ metadata: {
+ bulkCount: rules.length,
+ ...changeTracking?.metadata,
+ },
+ },
});
},
});
diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/rule_objects/revert_prebuilt_rules.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/rule_objects/revert_prebuilt_rules.ts
index 34d15964420f8..14e26703ac561 100644
--- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/rule_objects/revert_prebuilt_rules.ts
+++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/rule_objects/revert_prebuilt_rules.ts
@@ -23,6 +23,12 @@ export const revertPrebuiltRules = async (
ruleVersions: RuleTriad[]
) =>
withSecuritySpan('revertPrebuiltRule', async () => {
+ const changeTracking = {
+ metadata: {
+ bulkCount: ruleVersions.length,
+ },
+ };
+
const result = await initPromisePool({
concurrency: MAX_RULES_TO_UPDATE_IN_PARALLEL,
items: ruleVersions,
@@ -30,6 +36,7 @@ export const revertPrebuiltRules = async (
return detectionRulesClient.revertPrebuiltRule({
ruleAsset: target,
existingRule: current,
+ changeTracking,
});
},
});
diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/rule_objects/upgrade_prebuilt_rules.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/rule_objects/upgrade_prebuilt_rules.ts
index 32f0127ecc052..2804c2b2f7920 100644
--- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/rule_objects/upgrade_prebuilt_rules.ts
+++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/rule_objects/upgrade_prebuilt_rules.ts
@@ -6,6 +6,7 @@
*/
import type { Logger } from '@kbn/core/server';
+import type { SecurityRuleChangeTracking } from '../../../../../../common/detection_engine/rule_management/rule_change_tracking';
import { MAX_RULES_TO_UPDATE_IN_PARALLEL } from '../../../../../../common/constants';
import { initPromisePool } from '../../../../../utils/promise_pool';
import { withSecuritySpan } from '../../../../../utils/with_security_span';
@@ -23,6 +24,7 @@ import type { IDetectionRulesClient } from '../../../rule_management/logic/detec
export const upgradePrebuiltRules = async (
detectionRulesClient: IDetectionRulesClient,
rules: PrebuiltRuleAsset[],
+ changeTracking: SecurityRuleChangeTracking,
logger: Logger
) =>
withSecuritySpan('upgradePrebuiltRules', async () => {
@@ -35,6 +37,7 @@ export const upgradePrebuiltRules = async (
executor: async (rule) => {
return detectionRulesClient.upgradePrebuiltRule({
ruleAsset: rule,
+ changeTracking,
});
},
});
diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.ts
index f90ee745cd690..0a7adce22d097 100644
--- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.ts
+++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.ts
@@ -15,6 +15,7 @@ import type {
GapReasonType,
} from '@kbn/alerting-plugin/common';
import { RULES_API_ALL, RULES_API_READ } from '@kbn/security-solution-features/constants';
+import { SecurityRuleChangeTrackingAction } from '../../../../../../../common/detection_engine/rule_management/rule_change_tracking';
import { validateRuleResponseActions } from '../../../../../../endpoint/services';
import type { PerformRulesBulkActionResponse } from '../../../../../../../common/api/detection_engine/rule_management';
import {
@@ -329,8 +330,19 @@ export const performBulkActionRoute = (
const createdRule = await rulesClient.create({
data: duplicateRuleToCreate,
+ changeTracking: {
+ action: SecurityRuleChangeTrackingAction.ruleDuplicate,
+ metadata: {
+ bulkCount: rules.length,
+ originalRuleSoId: rule.id,
+ },
+ },
});
+ if (!shouldDuplicateExceptions) {
+ return createdRule;
+ }
+
// we try to create exceptions after rule created, and then update rule
const exceptions = shouldDuplicateExceptions
? await duplicateExceptions({
@@ -350,6 +362,11 @@ export const performBulkActionRoute = (
exceptionsList: exceptions,
},
},
+ changeTracking: {
+ metadata: {
+ bulkCount: rules.length,
+ },
+ },
shouldIncrementRevision: () => false,
});
diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/import_rules/route.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/import_rules/route.ts
index 648aba431cfb3..1ab568c3242bf 100644
--- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/import_rules/route.ts
+++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/import_rules/route.ts
@@ -191,6 +191,11 @@ export const importRulesRoute = (
const importRuleResponse = await importRules({
ruleChunks,
+ changeTracking: {
+ metadata: {
+ bulkCount: validatedResponseActionsRules.length,
+ },
+ },
overwriteRules: request.query.overwrite,
allowMissingConnectorSecrets: !!actionConnectors.length,
ruleSourceImporter,
diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.change_tracking.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.change_tracking.test.ts
new file mode 100644
index 0000000000000..e310f703ddb55
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.change_tracking.test.ts
@@ -0,0 +1,287 @@
+/*
+ * 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 { rulesClientMock } from '@kbn/alerting-plugin/server/mocks';
+import type { ActionsClient } from '@kbn/actions-plugin/server';
+import { savedObjectsClientMock } from '@kbn/core/server/mocks';
+import { licenseMock } from '@kbn/licensing-plugin/common/licensing.mock';
+
+import { SecurityRuleChangeTrackingAction } from '../../../../../../common/detection_engine/rule_management/rule_change_tracking';
+import {
+ getCreateEqlRuleSchemaMock,
+ getCreateRulesSchemaMock,
+ getRulesSchemaMock,
+ getRulesEqlSchemaMock,
+} from '../../../../../../common/api/detection_engine/model/rule_schema/mocks';
+import {
+ getImportRulesSchemaMock,
+ getValidatedRuleToImportMock,
+} from '../../../../../../common/api/detection_engine/rule_management/mocks';
+import type { PrebuiltRuleAsset } from '../../../prebuilt_rules';
+import { getRuleMock } from '../../../routes/__mocks__/request_responses';
+import { getQueryRuleParams, getEqlRuleParams } from '../../../rule_schema/mocks';
+import { buildMlAuthz } from '../../../../machine_learning/authz';
+import { createProductFeaturesServiceMock } from '../../../../product_features_service/mocks';
+import { createDetectionRulesClient } from './detection_rules_client';
+import type { IDetectionRulesClient } from './detection_rules_client_interface';
+import { getRuleByRuleId } from './methods/get_rule_by_rule_id';
+import { checkRuleExceptionReferences } from '../import/check_rule_exception_references';
+import { ruleSourceImporterMock } from '../import/rule_source_importer/rule_source_importer.mock';
+import { getMockRulesAuthz } from '../../__mocks__/authz';
+
+jest.mock('../../../../machine_learning/authz');
+jest.mock('../../../../machine_learning/validation');
+jest.mock('./methods/get_rule_by_rule_id');
+jest.mock('../import/check_rule_exception_references');
+
+describe('DetectionRulesClient change tracking', () => {
+ let rulesClient: ReturnType;
+ let detectionRulesClient: IDetectionRulesClient;
+
+ const mlAuthz = (buildMlAuthz as jest.Mock)();
+ const rulesAuthz = getMockRulesAuthz();
+ const actionsClient = {
+ isSystemAction: jest.fn((id: string) => id === 'system-connector-.cases'),
+ } as unknown as jest.Mocked;
+
+ beforeEach(() => {
+ rulesClient = rulesClientMock.create();
+ rulesClient.create.mockResolvedValue(getRuleMock(getQueryRuleParams()));
+ rulesClient.update.mockResolvedValue(getRuleMock(getQueryRuleParams()));
+ rulesClient.bulkDeleteRules.mockResolvedValue({
+ rules: [],
+ errors: [],
+ total: 1,
+ taskIdsFailedToBeDeleted: [],
+ });
+
+ (getRuleByRuleId as jest.Mock).mockResolvedValue(null);
+ (checkRuleExceptionReferences as jest.Mock).mockReturnValue([[], []]);
+
+ detectionRulesClient = createDetectionRulesClient({
+ actionsClient,
+ rulesClient,
+ mlAuthz,
+ rulesAuthz,
+ savedObjectsClient: savedObjectsClientMock.create(),
+ license: licenseMock.createLicenseMock(),
+ productFeaturesService: createProductFeaturesServiceMock(),
+ });
+ });
+
+ describe('changeTracking.action', () => {
+ it('createCustomRule forwards caller-provided action to rulesClient.create', async () => {
+ await detectionRulesClient.createCustomRule({
+ params: getCreateRulesSchemaMock(),
+ changeTracking: { action: SecurityRuleChangeTrackingAction.ruleDuplicate },
+ });
+
+ expect(rulesClient.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ changeTracking: expect.objectContaining({
+ action: SecurityRuleChangeTrackingAction.ruleDuplicate,
+ }),
+ })
+ );
+ });
+
+ it('updateRule forwards caller-provided action to rulesClient.update', async () => {
+ (getRuleByRuleId as jest.Mock).mockResolvedValueOnce(getRulesSchemaMock());
+
+ const ruleUpdate = getCreateRulesSchemaMock('query-rule-id');
+ ruleUpdate.name = 'updated name';
+
+ await detectionRulesClient.updateRule({
+ ruleUpdate,
+ changeTracking: { action: SecurityRuleChangeTrackingAction.ruleDuplicate },
+ });
+
+ expect(rulesClient.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ changeTracking: expect.objectContaining({
+ action: SecurityRuleChangeTrackingAction.ruleDuplicate,
+ }),
+ })
+ );
+ });
+
+ it('patchRule forwards caller-provided action to rulesClient.update', async () => {
+ const existingRule = getRulesSchemaMock();
+ (getRuleByRuleId as jest.Mock).mockResolvedValueOnce(existingRule);
+
+ await detectionRulesClient.patchRule({
+ rulePatch: { rule_id: existingRule.rule_id, name: 'patched name' },
+ changeTracking: { action: SecurityRuleChangeTrackingAction.ruleDuplicate },
+ });
+
+ expect(rulesClient.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ changeTracking: expect.objectContaining({
+ action: SecurityRuleChangeTrackingAction.ruleDuplicate,
+ }),
+ })
+ );
+ });
+
+ describe('importRule', () => {
+ it('uses ruleImport action when creating a new rule', async () => {
+ await detectionRulesClient.importRule({
+ ruleToImport: getValidatedRuleToImportMock(),
+ overwriteRules: true,
+ });
+
+ expect(rulesClient.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ changeTracking: expect.objectContaining({
+ action: SecurityRuleChangeTrackingAction.ruleImport,
+ }),
+ })
+ );
+ });
+
+ it('uses ruleImport action when overwriting an existing rule', async () => {
+ const existingRule = getRulesSchemaMock();
+ (getRuleByRuleId as jest.Mock).mockResolvedValueOnce(existingRule);
+
+ await detectionRulesClient.importRule({
+ ruleToImport: { ...getValidatedRuleToImportMock(), rule_id: existingRule.rule_id },
+ overwriteRules: true,
+ });
+
+ expect(rulesClient.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ changeTracking: expect.objectContaining({
+ action: SecurityRuleChangeTrackingAction.ruleImport,
+ }),
+ })
+ );
+ });
+ });
+
+ describe('upgradePrebuiltRule', () => {
+ it('uses ruleUpgrade action when upgrading a same-type rule', async () => {
+ const installedRule = getRulesEqlSchemaMock();
+ (getRuleByRuleId as jest.Mock).mockResolvedValueOnce(installedRule);
+ rulesClient.update.mockResolvedValue(getRuleMock(getEqlRuleParams()));
+
+ const ruleAsset: PrebuiltRuleAsset = {
+ ...getCreateEqlRuleSchemaMock(),
+ type: 'eql',
+ version: 1,
+ rule_id: installedRule.rule_id,
+ };
+
+ await detectionRulesClient.upgradePrebuiltRule({ ruleAsset });
+
+ expect(rulesClient.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ changeTracking: expect.objectContaining({
+ action: SecurityRuleChangeTrackingAction.ruleUpgrade,
+ }),
+ })
+ );
+ });
+
+ it('uses ruleUpgrade action when upgrading a rule with a type change', async () => {
+ const installedRule = getRulesSchemaMock(); // query type
+ installedRule.rule_id = 'rule-id';
+ (getRuleByRuleId as jest.Mock).mockResolvedValueOnce(installedRule);
+
+ const ruleAsset: PrebuiltRuleAsset = {
+ ...getCreateEqlRuleSchemaMock(), // eql type
+ type: 'eql',
+ version: 1,
+ rule_id: 'rule-id',
+ };
+
+ await detectionRulesClient.upgradePrebuiltRule({ ruleAsset });
+
+ expect(rulesClient.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ changeTracking: expect.objectContaining({
+ action: SecurityRuleChangeTrackingAction.ruleUpgrade,
+ }),
+ })
+ );
+ });
+ });
+
+ it('revertPrebuiltRule uses ruleRevert action', async () => {
+ const existingRule = getRulesEqlSchemaMock();
+ const ruleAsset: PrebuiltRuleAsset = {
+ ...getCreateEqlRuleSchemaMock(),
+ type: 'eql',
+ version: 1,
+ rule_id: existingRule.rule_id,
+ };
+ rulesClient.update.mockResolvedValue(getRuleMock(getEqlRuleParams()));
+
+ await detectionRulesClient.revertPrebuiltRule({ ruleAsset, existingRule });
+
+ expect(rulesClient.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ changeTracking: expect.objectContaining({
+ action: SecurityRuleChangeTrackingAction.ruleRevert,
+ }),
+ })
+ );
+ });
+ });
+
+ describe('changeTracking.bulkCount', () => {
+ it('bulkDeleteRules uses caller-provided bulkCount', async () => {
+ const ruleIds = ['id-1', 'id-2', 'id-3'];
+
+ await detectionRulesClient.bulkDeleteRules({
+ ruleIds,
+ changeTracking: { metadata: { bulkCount: 10 } },
+ });
+
+ expect(rulesClient.bulkDeleteRules).toHaveBeenCalledWith(
+ expect.objectContaining({
+ changeTracking: expect.objectContaining({ metadata: { bulkCount: 10 } }),
+ })
+ );
+ });
+
+ it('bulkDeleteRules defaults bulkCount to ruleIds.length when not provided', async () => {
+ const ruleIds = ['id-1', 'id-2', 'id-3'];
+
+ await detectionRulesClient.bulkDeleteRules({ ruleIds });
+
+ expect(rulesClient.bulkDeleteRules).toHaveBeenCalledWith(
+ expect.objectContaining({
+ changeTracking: expect.objectContaining({ metadata: { bulkCount: ruleIds.length } }),
+ })
+ );
+ });
+
+ it('importRules passes bulkCount through to importRule', async () => {
+ const importRuleSpy = jest
+ .spyOn(detectionRulesClient, 'importRule')
+ .mockResolvedValue(getRulesSchemaMock());
+ const mockRuleSourceImporter = ruleSourceImporterMock.create();
+ mockRuleSourceImporter.calculateRuleSource.mockReturnValue({
+ ruleSource: { type: 'internal' },
+ immutable: false,
+ });
+
+ await detectionRulesClient.importRules({
+ rules: [getImportRulesSchemaMock()],
+ overwriteRules: false,
+ ruleSourceImporter: mockRuleSourceImporter,
+ changeTracking: { metadata: { bulkCount: 5 } },
+ });
+
+ expect(importRuleSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ changeTracking: expect.objectContaining({ metadata: { bulkCount: 5 } }),
+ })
+ );
+ });
+ });
+});
diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.create_custom_rule.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.create_custom_rule.test.ts
index 1477a983bcabc..93e3d852215dd 100644
--- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.create_custom_rule.test.ts
+++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.create_custom_rule.test.ts
@@ -7,6 +7,7 @@
import { rulesClientMock } from '@kbn/alerting-plugin/server/mocks';
import type { ActionsClient } from '@kbn/actions-plugin/server';
+import { SecurityRuleChangeTrackingAction } from '../../../../../../common/detection_engine/rule_management/rule_change_tracking';
import { savedObjectsClientMock } from '@kbn/core/server/mocks';
import {
@@ -73,6 +74,9 @@ describe('DetectionRulesClient.createCustomRule', () => {
immutable: false,
}),
}),
+ options: expect.not.objectContaining({
+ action: SecurityRuleChangeTrackingAction.ruleInstall,
+ }),
})
);
});
diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.create_prebuilt_rule.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.create_prebuilt_rule.test.ts
index 38ff388b9a9f4..a218f3d1d6762 100644
--- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.create_prebuilt_rule.test.ts
+++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.create_prebuilt_rule.test.ts
@@ -7,6 +7,7 @@
import { rulesClientMock } from '@kbn/alerting-plugin/server/mocks';
import type { ActionsClient } from '@kbn/actions-plugin/server';
+import { SecurityRuleChangeTrackingAction } from '../../../../../../common/detection_engine/rule_management/rule_change_tracking';
import { savedObjectsClientMock } from '@kbn/core/server/mocks';
import {
@@ -55,7 +56,9 @@ describe('DetectionRulesClient.createPrebuiltRule', () => {
it('creates a rule with the correct parameters and options', async () => {
const params = { ...getCreateRulesSchemaMock(), version: 1, rule_id: 'rule-id' };
- await detectionRulesClient.createPrebuiltRule({ params });
+ await detectionRulesClient.createPrebuiltRule({
+ params,
+ });
expect(rulesClient.create).toHaveBeenCalledWith(
expect.objectContaining({
@@ -67,6 +70,9 @@ describe('DetectionRulesClient.createPrebuiltRule', () => {
immutable: true,
}),
}),
+ changeTracking: expect.objectContaining({
+ action: SecurityRuleChangeTrackingAction.ruleInstall,
+ }),
})
);
});
diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.import_rule.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.import_rule.test.ts
index a66d50ff5ea37..60b16fd7762ff 100644
--- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.import_rule.test.ts
+++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.import_rule.test.ts
@@ -7,6 +7,7 @@
import { rulesClientMock } from '@kbn/alerting-plugin/server/mocks';
import type { ActionsClient } from '@kbn/actions-plugin/server';
+import { SecurityRuleChangeTrackingAction } from '../../../../../../common/detection_engine/rule_management/rule_change_tracking';
import { savedObjectsClientMock } from '@kbn/core/server/mocks';
import { getRulesSchemaMock } from '../../../../../../common/api/detection_engine/model/rule_schema/mocks';
@@ -82,6 +83,9 @@ describe('DetectionRulesClient.importRule', () => {
}),
}),
allowMissingConnectorSecrets,
+ changeTracking: expect.objectContaining({
+ action: SecurityRuleChangeTrackingAction.ruleImport,
+ }),
})
);
});
@@ -127,6 +131,9 @@ describe('DetectionRulesClient.importRule', () => {
}),
}),
id: existingRule.id,
+ changeTracking: expect.objectContaining({
+ action: SecurityRuleChangeTrackingAction.ruleImport,
+ }),
})
);
});
diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.ts
index a7d96ef5aded5..d07c515b93a68 100644
--- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.ts
+++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.ts
@@ -11,6 +11,7 @@ import type { SavedObjectsClientContract } from '@kbn/core/server';
import { ProductFeatureKey } from '@kbn/security-solution-features/keys';
import type { ILicense } from '@kbn/licensing-types';
+import { SecurityRuleChangeTrackingAction } from '../../../../../../common/detection_engine/rule_management/rule_change_tracking';
import type { DetectionRulesAuthz } from '../../../../../../common/detection_engine/rule_management/authz';
import type { RuleResponse } from '../../../../../../common/api/detection_engine/model/rule_schema';
import { withSecuritySpan } from '../../../../../utils/with_security_span';
@@ -99,6 +100,7 @@ export const createDetectionRulesClient = ({
immutable: false,
},
mlAuthz,
+ changeTracking: args.changeTracking,
});
});
},
@@ -113,11 +115,15 @@ export const createDetectionRulesClient = ({
immutable: true,
},
mlAuthz,
+ changeTracking: {
+ action: SecurityRuleChangeTrackingAction.ruleInstall,
+ ...args.changeTracking,
+ },
});
});
},
- async updateRule({ ruleUpdate }: UpdateRuleArgs): Promise {
+ async updateRule({ ruleUpdate, changeTracking }: UpdateRuleArgs): Promise {
return withSecuritySpan('DetectionRulesClient.updateRule', async () => {
return updateRule({
actionsClient,
@@ -126,11 +132,12 @@ export const createDetectionRulesClient = ({
mlAuthz,
rulesAuthz,
ruleUpdate,
+ changeTracking,
});
});
},
- async patchRule({ rulePatch }: PatchRuleArgs): Promise {
+ async patchRule({ rulePatch, changeTracking }: PatchRuleArgs): Promise {
return withSecuritySpan('DetectionRulesClient.patchRule', async () => {
return patchRule({
actionsClient,
@@ -139,6 +146,7 @@ export const createDetectionRulesClient = ({
mlAuthz,
rulesAuthz,
rulePatch,
+ changeTracking,
});
});
},
@@ -149,13 +157,19 @@ export const createDetectionRulesClient = ({
});
},
- async bulkDeleteRules({ ruleIds }: BulkDeleteRulesArgs): Promise {
+ async bulkDeleteRules({
+ ruleIds,
+ changeTracking,
+ }: BulkDeleteRulesArgs): Promise {
return withSecuritySpan('DetectionRulesClient.bulkDeleteRules', async () => {
- return bulkDeleteRules({ rulesClient, ruleIds });
+ return bulkDeleteRules({ rulesClient, ruleIds, changeTracking });
});
},
- async upgradePrebuiltRule({ ruleAsset }: UpgradePrebuiltRuleArgs): Promise {
+ async upgradePrebuiltRule({
+ ruleAsset,
+ changeTracking,
+ }: UpgradePrebuiltRuleArgs): Promise {
return withSecuritySpan('DetectionRulesClient.upgradePrebuiltRule', async () => {
return upgradePrebuiltRule({
actionsClient,
@@ -163,6 +177,7 @@ export const createDetectionRulesClient = ({
ruleAsset,
mlAuthz,
prebuiltRuleAssetClient,
+ changeTracking,
});
});
},
@@ -170,6 +185,7 @@ export const createDetectionRulesClient = ({
async revertPrebuiltRule({
ruleAsset,
existingRule,
+ changeTracking,
}: RevertPrebuiltRuleArgs): Promise {
return withSecuritySpan('DetectionRulesClient.revertPrebuiltRule', async () => {
return revertPrebuiltRule({
@@ -179,6 +195,7 @@ export const createDetectionRulesClient = ({
mlAuthz,
prebuiltRuleAssetClient,
existingRule,
+ changeTracking,
});
});
},
@@ -191,6 +208,7 @@ export const createDetectionRulesClient = ({
importRulePayload: args,
mlAuthz,
prebuiltRuleAssetClient,
+ changeTracking: args.changeTracking,
});
});
},
diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.upgrade_prebuilt_rule.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.upgrade_prebuilt_rule.test.ts
index 1ff322a29ccd3..39a0899673129 100644
--- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.upgrade_prebuilt_rule.test.ts
+++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.upgrade_prebuilt_rule.test.ts
@@ -7,6 +7,7 @@
import { rulesClientMock } from '@kbn/alerting-plugin/server/mocks';
import type { ActionsClient } from '@kbn/actions-plugin/server';
+import { SecurityRuleChangeTrackingAction } from '../../../../../../common/detection_engine/rule_management/rule_change_tracking';
import {
getCreateEqlRuleSchemaMock,
@@ -152,9 +153,12 @@ describe('DetectionRulesClient.upgradePrebuiltRule', () => {
exceptionsList: installedRule.exceptions_list,
}),
}),
- options: {
+ options: expect.objectContaining({
id: installedRule.id, // id is maintained
- },
+ }),
+ changeTracking: expect.objectContaining({
+ action: SecurityRuleChangeTrackingAction.ruleUpgrade,
+ }),
})
);
});
@@ -224,6 +228,9 @@ describe('DetectionRulesClient.upgradePrebuiltRule', () => {
}),
}),
id: installedRule.id,
+ changeTracking: expect.objectContaining({
+ action: SecurityRuleChangeTrackingAction.ruleUpgrade,
+ }),
})
);
});
diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client_interface.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client_interface.ts
index 067c5dbc25833..a463b26dbc53f 100644
--- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client_interface.ts
+++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client_interface.ts
@@ -6,6 +6,7 @@
*/
import type { BulkOperationError } from '@kbn/alerting-plugin/server';
+import type { SecurityRuleChangeTracking } from '../../../../../../common/detection_engine/rule_management/rule_change_tracking';
import type {
RuleCreateProps,
RuleUpdateProps,
@@ -39,18 +40,22 @@ export interface IDetectionRulesClient {
export interface CreateCustomRuleArgs {
params: RuleCreateProps;
+ changeTracking?: SecurityRuleChangeTracking;
}
export interface CreatePrebuiltRuleArgs {
params: RuleCreateProps;
+ changeTracking?: SecurityRuleChangeTracking;
}
export interface UpdateRuleArgs {
ruleUpdate: RuleUpdateProps;
+ changeTracking?: SecurityRuleChangeTracking;
}
export interface PatchRuleArgs {
rulePatch: RulePatchProps;
+ changeTracking?: SecurityRuleChangeTracking;
}
export interface DeleteRuleArgs {
@@ -59,6 +64,7 @@ export interface DeleteRuleArgs {
export interface BulkDeleteRulesArgs {
ruleIds: RuleObjectId[];
+ changeTracking?: SecurityRuleChangeTracking;
}
export interface BulkDeleteRulesReturn {
@@ -68,11 +74,13 @@ export interface BulkDeleteRulesReturn {
export interface UpgradePrebuiltRuleArgs {
ruleAsset: PrebuiltRuleAsset;
+ changeTracking?: SecurityRuleChangeTracking;
}
export interface RevertPrebuiltRuleArgs {
ruleAsset: PrebuiltRuleAsset;
existingRule: RuleResponse;
+ changeTracking?: SecurityRuleChangeTracking;
}
export interface ImportRuleArgs {
@@ -80,6 +88,7 @@ export interface ImportRuleArgs {
overrideFields?: { rule_source: RuleSource; immutable: boolean };
overwriteRules?: boolean;
allowMissingConnectorSecrets?: boolean;
+ changeTracking?: SecurityRuleChangeTracking;
}
export interface ImportRulesArgs {
@@ -87,6 +96,7 @@ export interface ImportRulesArgs {
overwriteRules: boolean;
ruleSourceImporter: IRuleSourceImporter;
allowMissingConnectorSecrets?: boolean;
+ changeTracking?: SecurityRuleChangeTracking;
}
export interface GetHistoryForRuleArgs {
diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/bulk_delete_rules.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/bulk_delete_rules.ts
index f49718ab2f9a5..21070526f6214 100644
--- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/bulk_delete_rules.ts
+++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/bulk_delete_rules.ts
@@ -7,6 +7,7 @@
import { chunk } from 'lodash';
import type { RulesClient, BulkOperationError } from '@kbn/alerting-plugin/server';
+import type { SecurityRuleChangeTracking } from '../../../../../../../common/detection_engine/rule_management/rule_change_tracking';
import type { RuleObjectId } from '../../../../../../../common/api/detection_engine';
import type { RuleAlertType } from '../../../../rule_schema';
@@ -18,18 +19,23 @@ const CHUNK_SIZE = 1000;
interface BulkDeleteRulesParams {
rulesClient: RulesClient;
ruleIds: RuleObjectId[];
+ changeTracking?: SecurityRuleChangeTracking;
}
export const bulkDeleteRules = async ({
rulesClient,
ruleIds,
+ changeTracking,
}: BulkDeleteRulesParams): Promise<{ rules: RuleAlertType[]; errors: BulkOperationError[] }> => {
const chunks = chunk(ruleIds, CHUNK_SIZE);
const allRules: RuleAlertType[] = [];
const allErrors: BulkOperationError[] = [];
for (const idsChunk of chunks) {
- const { rules, errors } = await rulesClient.bulkDeleteRules({ ids: idsChunk });
+ const { rules, errors } = await rulesClient.bulkDeleteRules({
+ ids: idsChunk,
+ changeTracking: { metadata: { bulkCount: ruleIds.length, ...changeTracking?.metadata } },
+ });
allRules.push(...(rules as RuleAlertType[]));
allErrors.push(...errors);
}
diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/create_rule.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/create_rule.ts
index 7bd3c9c46bf73..79bc762ea8088 100644
--- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/create_rule.ts
+++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/create_rule.ts
@@ -8,6 +8,7 @@
import type { RulesClient } from '@kbn/alerting-plugin/server';
import type { ActionsClient } from '@kbn/actions-plugin/server';
import { ruleTypeMappings } from '@kbn/securitysolution-rules';
+import type { SecurityRuleChangeTracking } from '../../../../../../../common/detection_engine/rule_management/rule_change_tracking';
import { SERVER_APP_ID } from '../../../../../../../common';
import type {
RuleCreateProps,
@@ -27,6 +28,7 @@ interface CreateRuleOptions {
rule: RuleCreateProps & { immutable: boolean };
id?: string;
allowMissingConnectorSecrets?: boolean;
+ changeTracking?: SecurityRuleChangeTracking;
}
export const createRule = async ({
@@ -36,6 +38,7 @@ export const createRule = async ({
rule,
id,
allowMissingConnectorSecrets,
+ changeTracking,
}: CreateRuleOptions): Promise => {
await validateMlAuth(mlAuthz, rule.type);
@@ -50,7 +53,10 @@ export const createRule = async ({
const createdRule = await rulesClient.create({
data: payload,
- options: { id },
+ options: {
+ id,
+ },
+ changeTracking,
allowMissingConnectorSecrets,
});
diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/import_rule.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/import_rule.ts
index 78298d5b0e689..bd817838d5ac7 100644
--- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/import_rule.ts
+++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/import_rule.ts
@@ -8,6 +8,8 @@
import type { RulesClient } from '@kbn/alerting-plugin/server';
import type { ActionsClient } from '@kbn/actions-plugin/server';
+import type { SecurityRuleChangeTracking } from '../../../../../../../common/detection_engine/rule_management/rule_change_tracking';
+import { SecurityRuleChangeTrackingAction } from '../../../../../../../common/detection_engine/rule_management/rule_change_tracking';
import type { RuleResponse } from '../../../../../../../common/api/detection_engine/model/rule_schema';
import type { MlAuthz } from '../../../../../machine_learning/authz';
import type { IPrebuiltRuleAssetsClient } from '../../../../prebuilt_rules/logic/rule_assets/prebuilt_rule_assets_client';
@@ -26,6 +28,7 @@ interface ImportRuleOptions {
prebuiltRuleAssetClient: IPrebuiltRuleAssetsClient;
importRulePayload: ImportRuleArgs;
mlAuthz: MlAuthz;
+ changeTracking?: SecurityRuleChangeTracking;
}
export const importRule = async ({
@@ -34,6 +37,7 @@ export const importRule = async ({
importRulePayload,
prebuiltRuleAssetClient,
mlAuthz,
+ changeTracking,
}: ImportRuleOptions): Promise => {
const { ruleToImport, overwriteRules, overrideFields, allowMissingConnectorSecrets } =
importRulePayload;
@@ -67,6 +71,7 @@ export const importRule = async ({
const updatedRule = await rulesClient.update({
id: existingRule.id,
data: convertRuleResponseToAlertingRule(ruleWithUpdates, actionsClient),
+ changeTracking: { action: SecurityRuleChangeTrackingAction.ruleImport, ...changeTracking },
});
// We strip `enabled` from the rule object to use in the rules client and need to enable it separately if user has enabled the updated rule
@@ -82,5 +87,6 @@ export const importRule = async ({
mlAuthz,
rule,
allowMissingConnectorSecrets,
+ changeTracking: { ...changeTracking, action: SecurityRuleChangeTrackingAction.ruleImport },
});
};
diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/import_rules.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/import_rules.ts
index c045e1641b197..50d2555134a09 100644
--- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/import_rules.ts
+++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/import_rules.ts
@@ -7,6 +7,7 @@
import { i18n } from '@kbn/i18n';
import type { SavedObjectsClientContract } from '@kbn/core/server';
+import type { SecurityRuleChangeTracking } from '../../../../../../../common/detection_engine/rule_management/rule_change_tracking';
import type { RuleResponse, RuleToImport } from '../../../../../../../common/api/detection_engine';
import { ruleToImportHasVersion } from '../../../../../../../common/api/detection_engine/rule_management';
@@ -31,6 +32,7 @@ export const importRules = async ({
ruleSourceImporter,
rules,
savedObjectsClient,
+ changeTracking,
}: {
allowMissingConnectorSecrets?: boolean;
detectionRulesClient: IDetectionRulesClient;
@@ -38,6 +40,7 @@ export const importRules = async ({
ruleSourceImporter: IRuleSourceImporter;
rules: RuleToImport[];
savedObjectsClient: SavedObjectsClientContract;
+ changeTracking?: SecurityRuleChangeTracking;
}): Promise> => {
const existingLists = await getReferencedExceptionLists({
rules,
@@ -80,6 +83,7 @@ export const importRules = async ({
...rule,
exceptions_list: [...exceptions],
},
+ changeTracking,
overrideFields: { rule_source: ruleSource, immutable },
overwriteRules,
allowMissingConnectorSecrets,
diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/patch_rule.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/patch_rule.ts
index 1800b3e582c87..fb32a0e9d542a 100644
--- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/patch_rule.ts
+++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/patch_rule.ts
@@ -10,6 +10,7 @@ import type { ActionsClient } from '@kbn/actions-plugin/server';
import { isEmpty, isEqual } from 'lodash';
import type { BulkEditResult } from '@kbn/alerting-plugin/server/rules_client/common/bulk_edit/types';
+import type { SecurityRuleChangeTracking } from '../../../../../../../common/detection_engine/rule_management/rule_change_tracking';
import type { DetectionRulesAuthz } from '../../../../../../../common/detection_engine/rule_management/authz';
import type {
RulePatchProps,
@@ -41,6 +42,7 @@ interface PatchRuleOptions {
rulePatch: RulePatchProps;
mlAuthz: MlAuthz;
rulesAuthz: DetectionRulesAuthz;
+ changeTracking?: SecurityRuleChangeTracking;
}
export const patchRule = async ({
@@ -50,6 +52,7 @@ export const patchRule = async ({
rulePatch,
mlAuthz,
rulesAuthz,
+ changeTracking,
}: PatchRuleOptions): Promise => {
const { rule_id: ruleId, id, ...rulePatchObjWithoutIds } = rulePatch;
@@ -111,6 +114,7 @@ export const patchRule = async ({
rulesClient,
ruleUpdate: { ...fieldsToPatch, rule_source: patchedRule.rule_source },
existingRule,
+ changeTracking,
});
const patchErrors = formatBulkEditResultErrors(appliedPatchWithReadPrivs);
@@ -132,6 +136,7 @@ export const patchRule = async ({
const patchedInternalRule = await rulesClient.update({
id: existingRule.id,
data: convertRuleResponseToAlertingRule(patchedRule, actionsClient),
+ changeTracking,
});
const { enabled } = await toggleRuleEnabledOnUpdate(rulesClient, existingRule, patchedRule);
diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/rbac_methods/update_rule_with_read_privileges.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/rbac_methods/update_rule_with_read_privileges.ts
index e5f08b60650dc..6f1bbc274a354 100644
--- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/rbac_methods/update_rule_with_read_privileges.ts
+++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/rbac_methods/update_rule_with_read_privileges.ts
@@ -9,6 +9,7 @@ import type { RulesClient } from '@kbn/alerting-plugin/server';
import { camelCase } from 'lodash';
import type { BulkEditResult } from '@kbn/alerting-plugin/server/rules_client/common/bulk_edit/types';
import type { ValidReadAuthEditFields } from '@kbn/alerting-plugin/common/constants';
+import type { SecurityRuleChangeTracking } from '../../../../../../../../common/detection_engine/rule_management/rule_change_tracking';
import type { ReadAuthRuleUpdateWithRuleSource } from '../../../../../../../../common/api/detection_engine';
import type { RuleParams } from '../../../../../rule_schema';
import type { RuleResponse } from '../../../../../../../../common/api/detection_engine/model/rule_schema';
@@ -23,10 +24,12 @@ export const updateReadAuthEditRuleFields = async ({
rulesClient,
ruleUpdate,
existingRule,
+ changeTracking,
}: {
rulesClient: RulesClient;
ruleUpdate: ReadAuthRuleUpdateWithRuleSource;
existingRule: RuleResponse;
+ changeTracking?: SecurityRuleChangeTracking;
}): Promise> => {
const operations = Object.keys(ruleUpdate).map((field) => {
const camelCasedField = camelCase(field) as ValidReadAuthEditFields; // RuleParams schema is camel cased
@@ -40,5 +43,6 @@ export const updateReadAuthEditRuleFields = async ({
return rulesClient.bulkEditRuleParamsWithReadAuth({
ids: [existingRule.id],
operations,
+ changeTracking,
});
};
diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/revert_prebuilt_rule.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/revert_prebuilt_rule.ts
index b9390ae8ac64f..51755ab474007 100644
--- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/revert_prebuilt_rule.ts
+++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/revert_prebuilt_rule.ts
@@ -7,7 +7,8 @@
import type { RulesClient } from '@kbn/alerting-plugin/server';
import type { ActionsClient } from '@kbn/actions-plugin/server';
-
+import type { SecurityRuleChangeTracking } from '../../../../../../../common/detection_engine/rule_management/rule_change_tracking';
+import { SecurityRuleChangeTrackingAction } from '../../../../../../../common/detection_engine/rule_management/rule_change_tracking';
import type { RuleResponse } from '../../../../../../../common/api/detection_engine/model/rule_schema';
import type { MlAuthz } from '../../../../../machine_learning/authz';
import type { PrebuiltRuleAsset } from '../../../../prebuilt_rules';
@@ -24,6 +25,7 @@ export const revertPrebuiltRule = async ({
mlAuthz,
existingRule,
prebuiltRuleAssetClient,
+ changeTracking,
}: {
actionsClient: ActionsClient;
rulesClient: RulesClient;
@@ -31,6 +33,7 @@ export const revertPrebuiltRule = async ({
mlAuthz: MlAuthz;
existingRule: RuleResponse;
prebuiltRuleAssetClient: IPrebuiltRuleAssetsClient;
+ changeTracking?: SecurityRuleChangeTracking;
}): Promise => {
await validateMlAuth(mlAuthz, ruleAsset.type);
const updatedRule = await applyRuleUpdate({
@@ -49,6 +52,10 @@ export const revertPrebuiltRule = async ({
const updatedInternalRule = await rulesClient.update({
id: existingRule.id,
data: convertRuleResponseToAlertingRule(updatedRuleWithMergedExceptions, actionsClient),
+ changeTracking: {
+ action: SecurityRuleChangeTrackingAction.ruleRevert,
+ ...changeTracking,
+ },
});
return convertAlertingRuleToRuleResponse(updatedInternalRule);
diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/update_rule.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/update_rule.ts
index 0fa5a0a836f77..555efb3576d77 100644
--- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/update_rule.ts
+++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/update_rule.ts
@@ -4,10 +4,12 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
+
import type { ActionsClient } from '@kbn/actions-plugin/server';
import type { RulesClient } from '@kbn/alerting-plugin/server';
import type { BulkEditResult } from '@kbn/alerting-plugin/server/rules_client/common/bulk_edit/types';
+import type { SecurityRuleChangeTracking } from '../../../../../../../common/detection_engine/rule_management/rule_change_tracking';
import type { DetectionRulesAuthz } from '../../../../../../../common/detection_engine/rule_management/authz';
import type { RuleResponse } from '../../../../../../../common/api/detection_engine/model/rule_schema';
import type { MlAuthz } from '../../../../../machine_learning/authz';
@@ -39,6 +41,7 @@ interface UpdateRuleArguments {
ruleUpdate: RuleUpdateProps;
mlAuthz: MlAuthz;
rulesAuthz: DetectionRulesAuthz;
+ changeTracking?: SecurityRuleChangeTracking;
}
export const updateRule = async ({
@@ -48,6 +51,7 @@ export const updateRule = async ({
ruleUpdate,
mlAuthz,
rulesAuthz,
+ changeTracking,
}: UpdateRuleArguments): Promise => {
const { rule_id: ruleId, id } = ruleUpdate;
@@ -93,6 +97,7 @@ export const updateRule = async ({
rulesClient,
ruleUpdate: modifiedFields,
existingRule,
+ changeTracking,
});
const updateErrors = formatBulkEditResultErrors(appliedUpdateWithReadPrivs);
@@ -114,6 +119,7 @@ export const updateRule = async ({
const updatedRule = await rulesClient.update({
id: existingRule.id,
data: convertRuleResponseToAlertingRule(ruleWithUpdates, actionsClient),
+ changeTracking,
});
const { enabled } = await toggleRuleEnabledOnUpdate(rulesClient, existingRule, ruleWithUpdates);
diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/upgrade_prebuilt_rule.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/upgrade_prebuilt_rule.ts
index 7c2bda89180e2..50096bcc5ba22 100644
--- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/upgrade_prebuilt_rule.ts
+++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/upgrade_prebuilt_rule.ts
@@ -7,7 +7,8 @@
import type { RulesClient } from '@kbn/alerting-plugin/server';
import type { ActionsClient } from '@kbn/actions-plugin/server';
-
+import type { SecurityRuleChangeTracking } from '../../../../../../../common/detection_engine/rule_management/rule_change_tracking';
+import { SecurityRuleChangeTrackingAction } from '../../../../../../../common/detection_engine/rule_management/rule_change_tracking';
import type { RuleResponse } from '../../../../../../../common/api/detection_engine/model/rule_schema';
import type { MlAuthz } from '../../../../../machine_learning/authz';
import type { PrebuiltRuleAsset } from '../../../../prebuilt_rules';
@@ -25,12 +26,14 @@ export const upgradePrebuiltRule = async ({
ruleAsset,
mlAuthz,
prebuiltRuleAssetClient,
+ changeTracking,
}: {
actionsClient: ActionsClient;
rulesClient: RulesClient;
ruleAsset: PrebuiltRuleAsset;
mlAuthz: MlAuthz;
prebuiltRuleAssetClient: IPrebuiltRuleAssetsClient;
+ changeTracking?: SecurityRuleChangeTracking;
}): Promise => {
await validateMlAuth(mlAuthz, ruleAsset.type);
@@ -63,6 +66,7 @@ export const upgradePrebuiltRule = async ({
timeline_title: existingRule.timeline_title,
},
id: existingRule.id,
+ changeTracking: { action: SecurityRuleChangeTrackingAction.ruleUpgrade, ...changeTracking },
});
return createdRule;
@@ -85,6 +89,10 @@ export const upgradePrebuiltRule = async ({
const updatedInternalRule = await rulesClient.update({
id: existingRule.id,
data: convertRuleResponseToAlertingRule(updatedRuleWithMergedExceptions, actionsClient),
+ changeTracking: {
+ action: SecurityRuleChangeTrackingAction.ruleUpgrade,
+ ...changeTracking,
+ },
});
return convertAlertingRuleToRuleResponse(updatedInternalRule);
diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/import_rules.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/import_rules.ts
index 012c64c0ad8a4..b1fda8646b390 100644
--- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/import_rules.ts
+++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/import_rules.ts
@@ -5,6 +5,7 @@
* 2.0.
*/
+import type { SecurityRuleChangeTracking } from '../../../../../../common/detection_engine/rule_management/rule_change_tracking';
import type { RuleToImport } from '../../../../../../common/api/detection_engine';
import { type ImportRuleResponse, createBulkErrorObject } from '../../../routes/utils';
import type { IRuleSourceImporter } from './rule_source_importer';
@@ -22,12 +23,14 @@ import { isRuleConflictError, isRuleImportError } from './errors';
*/
export const importRules = async ({
ruleChunks,
+ changeTracking,
overwriteRules,
detectionRulesClient,
ruleSourceImporter,
allowMissingConnectorSecrets,
}: {
ruleChunks: RuleToImport[][];
+ changeTracking?: SecurityRuleChangeTracking;
overwriteRules: boolean;
detectionRulesClient: IDetectionRulesClient;
ruleSourceImporter: IRuleSourceImporter;
@@ -45,6 +48,7 @@ export const importRules = async ({
overwriteRules,
ruleSourceImporter,
rules,
+ changeTracking,
});
const importResponses = importedRulesResponse.map((rule) => {
diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_management/trial_license_complete_tier/change_tracking.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_management/trial_license_complete_tier/change_tracking.ts
new file mode 100644
index 0000000000000..7ab8263d4ae69
--- /dev/null
+++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_management/trial_license_complete_tier/change_tracking.ts
@@ -0,0 +1,503 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import expect from 'expect';
+import { v4 as uuidv4 } from 'uuid';
+import { ModeEnum } from '@kbn/security-solution-plugin/common/api/detection_engine';
+import { BulkActionTypeEnum } from '@kbn/security-solution-plugin/common/api/detection_engine/rule_management';
+import { DETECTION_ENGINE_RULES_IMPORT_URL } from '@kbn/security-solution-plugin/common/constants';
+import { deleteAllRules } from '@kbn/detections-response-ftr-services';
+import type { FtrProviderContext } from '../../../../../ftr_provider_context';
+import {
+ combineToNdJson,
+ createHistoricalPrebuiltRuleAssetSavedObjects,
+ createPrebuiltRuleAssetSavedObjects,
+ createRuleAssetSavedObject,
+ deleteAllPrebuiltRuleAssets,
+ getCustomQueryRuleParams,
+ installPrebuiltRules,
+ performUpgradePrebuiltRules,
+} from '../../../utils';
+import { revertPrebuiltRule } from '../../../utils/rules/prebuilt_rules/revert_prebuilt_rule';
+
+const CHANGE_HISTORY_DATA_STREAM = '.kibana_change_history';
+
+export default ({ getService }: FtrProviderContext): void => {
+ const supertest = getService('supertest');
+ const detectionsApi = getService('detectionsApi');
+ const es = getService('es');
+ const log = getService('log');
+
+ const refreshHistory = async () => {
+ await es.indices.refresh({ index: CHANGE_HISTORY_DATA_STREAM, ignore_unavailable: true });
+ };
+
+ const clearHistory = async () => {
+ try {
+ await es.deleteByQuery({
+ index: CHANGE_HISTORY_DATA_STREAM,
+ query: { match_all: {} },
+ conflicts: 'proceed',
+ refresh: true,
+ });
+ } catch {
+ // Change history index may not exist yet
+ }
+ };
+
+ // Skipped until a feature flag in @kbn/change-history package is enabled
+ describe.skip('@ess @serverless @serverlessQA rule change history', () => {
+ beforeEach(async () => {
+ await deleteAllRules(supertest, log);
+ await deleteAllPrebuiltRuleAssets(es, log);
+ await clearHistory();
+ });
+
+ describe('history API', () => {
+ it('returns the rule_create record for a newly-created rule', async () => {
+ const { body: rule } = await detectionsApi
+ .createRule({ body: getCustomQueryRuleParams() })
+ .expect(200);
+
+ await refreshHistory();
+
+ const { body } = await detectionsApi
+ .ruleChangesHistory({ params: { ruleId: rule.id }, query: {} })
+ .expect(200);
+
+ expect(body.page).toBe(1);
+ expect(body.perPage).toBe(20);
+ expect(body.total).toBe(1);
+ expect(body.items).toHaveLength(1);
+
+ const [item] = body.items;
+ expect(item.action).toBe('rule_create');
+ expect(item.user).toEqual({ name: 'elastic' });
+ expect(item.rule).toMatchObject({ id: rule.id, revision: 0 });
+ expect(item.old_values).toBeNull();
+ });
+
+ it('returns 404 when the rule does not exist', async () => {
+ await detectionsApi
+ .ruleChangesHistory({ params: { ruleId: uuidv4() }, query: {} })
+ .expect(404);
+ });
+
+ it('rejects the request when the "ruleId" path parameter is missing', async () => {
+ // @ts-expect-error testing missing id
+ await detectionsApi.ruleChangesHistory({ params: {}, query: {} }).expect(400);
+ });
+
+ it('rejects the request when per_page exceeds the maximum', async () => {
+ const { body: rule } = await detectionsApi
+ .createRule({ body: getCustomQueryRuleParams() })
+ .expect(200);
+
+ await detectionsApi
+ .ruleChangesHistory({ params: { ruleId: rule.id }, query: { per_page: 101 } })
+ .expect(400);
+ });
+
+ describe('pagination', () => {
+ let ruleId: string;
+
+ beforeEach(async () => {
+ // Create the rule (revision 0, rule_create) and update it four times
+ // (revisions 1-4, rule_update) so there are 5 history records.
+ const { body: rule } = await detectionsApi
+ .createRule({ body: getCustomQueryRuleParams() })
+ .expect(200);
+
+ ruleId = rule.id;
+
+ for (let i = 1; i <= 4; i++) {
+ await detectionsApi
+ .updateRule({
+ body: getCustomQueryRuleParams({ rule_id: rule.rule_id, name: `name-${i}` }),
+ })
+ .expect(200);
+ }
+
+ await refreshHistory();
+ });
+
+ it('returns the requested page with the right size and total', async () => {
+ const { body } = await detectionsApi
+ .ruleChangesHistory({ params: { ruleId }, query: { page: 1, per_page: 2 } })
+ .expect(200);
+
+ expect(body.total).toBe(5);
+ expect(body.page).toBe(1);
+ expect(body.perPage).toBe(2);
+ expect(body.items).toHaveLength(2);
+ expect(body.items[0].rule.revision).toBe(4);
+ expect(body.items[1].rule.revision).toBe(3);
+ });
+
+ it('returns subsequent pages without overlap', async () => {
+ const { body: page2 } = await detectionsApi
+ .ruleChangesHistory({ params: { ruleId }, query: { page: 2, per_page: 2 } })
+ .expect(200);
+
+ expect(page2.items).toHaveLength(2);
+ expect(page2.items[0].rule.revision).toBe(2);
+ expect(page2.items[1].rule.revision).toBe(1);
+ });
+
+ it('returns fewer items on the last partial page', async () => {
+ const { body: page3 } = await detectionsApi
+ .ruleChangesHistory({ params: { ruleId }, query: { page: 3, per_page: 2 } })
+ .expect(200);
+
+ expect(page3.items).toHaveLength(1);
+ expect(page3.items[0].rule.revision).toBe(0);
+ expect(page3.items[0].old_values).toBeNull();
+ });
+
+ it('computes `old_values` against the next-older revision across page boundaries', async () => {
+ // Oldest item on page 1 (revision 3) should still see revision 2 as
+ // its predecessor — provided by the perPage+1 lookback fetch.
+ const { body: page1 } = await detectionsApi
+ .ruleChangesHistory({ params: { ruleId }, query: { page: 1, per_page: 2 } })
+ .expect(200);
+
+ expect(page1.items[1].old_values).not.toBeNull();
+ expect(page1.items[1].old_values.revision).toBe(2);
+ });
+ });
+
+ describe('field-level changes (old_values merge patch)', () => {
+ it('emits only the changed top-level field in `old_values`', async () => {
+ const { body: rule } = await detectionsApi
+ .createRule({ body: getCustomQueryRuleParams({ name: 'name-A' }) })
+ .expect(200);
+
+ await detectionsApi
+ .updateRule({
+ body: getCustomQueryRuleParams({ rule_id: rule.rule_id, name: 'name-B' }),
+ })
+ .expect(200);
+
+ await refreshHistory();
+
+ const { body } = await detectionsApi
+ .ruleChangesHistory({ params: { ruleId: rule.id }, query: {} })
+ .expect(200);
+
+ expect(body.total).toBe(2);
+ expect(body.items[0].rule.name).toBe('name-B');
+ expect(body.items[0].old_values?.name).toBe('name-A');
+ });
+
+ it('emits multiple changed fields in a single patch', async () => {
+ const { body: rule } = await detectionsApi
+ .createRule({ body: getCustomQueryRuleParams({ name: 'name-A', tags: ['x'] }) })
+ .expect(200);
+
+ await detectionsApi
+ .updateRule({
+ body: getCustomQueryRuleParams({
+ rule_id: rule.rule_id,
+ name: 'name-B',
+ tags: ['x', 'y'],
+ }),
+ })
+ .expect(200);
+
+ await refreshHistory();
+
+ const { body } = await detectionsApi
+ .ruleChangesHistory({ params: { ruleId: rule.id }, query: {} })
+ .expect(200);
+
+ expect(body.items[0].old_values?.name).toBe('name-A');
+ expect(body.items[0].old_values?.tags).toEqual(['x']);
+ });
+
+ it('returns null `old_values` for the creation event', async () => {
+ const { body: rule } = await detectionsApi
+ .createRule({ body: getCustomQueryRuleParams() })
+ .expect(200);
+
+ await refreshHistory();
+
+ const { body } = await detectionsApi
+ .ruleChangesHistory({ params: { ruleId: rule.id }, query: {} })
+ .expect(200);
+
+ expect(body.items[0].old_values).toBeNull();
+ });
+ });
+ });
+
+ describe('action', () => {
+ it('records rule_create when creating a custom rule', async () => {
+ const { body: rule } = await detectionsApi
+ .createRule({ body: getCustomQueryRuleParams() })
+ .expect(200);
+
+ await refreshHistory();
+
+ const { body } = await detectionsApi
+ .ruleChangesHistory({ params: { ruleId: rule.id }, query: {} })
+ .expect(200);
+
+ expect(body.items).toHaveLength(1);
+ expect(body.items[0].action).toBe('rule_create');
+ });
+
+ it('records rule_update when updating a rule', async () => {
+ const { body: rule } = await detectionsApi
+ .createRule({ body: getCustomQueryRuleParams() })
+ .expect(200);
+
+ await detectionsApi
+ .updateRule({
+ body: getCustomQueryRuleParams({ rule_id: rule.rule_id, name: 'updated name' }),
+ })
+ .expect(200);
+
+ await refreshHistory();
+
+ const { body } = await detectionsApi
+ .ruleChangesHistory({ params: { ruleId: rule.id }, query: {} })
+ .expect(200);
+
+ expect(body.items[0].action).toBe('rule_update');
+ });
+
+ it('records rule_import when importing a new rule', async () => {
+ const ruleId = 'import-action-test-rule';
+ const ndjson = combineToNdJson(getCustomQueryRuleParams({ rule_id: ruleId }));
+
+ await detectionsApi
+ .importRules({ query: { overwrite: true } })
+ .attach('file', Buffer.from(ndjson), 'rules.ndjson')
+ .expect(200);
+
+ const { body: rule } = await detectionsApi.readRule({ query: { rule_id: ruleId } });
+
+ await refreshHistory();
+
+ const { body } = await detectionsApi
+ .ruleChangesHistory({ params: { ruleId: rule.id }, query: {} })
+ .expect(200);
+
+ expect(body.items).toHaveLength(1);
+ expect(body.items[0].action).toBe('rule_import');
+ });
+
+ it('records rule_import when overwriting an existing rule', async () => {
+ const ruleId = 'overwrite-action-test-rule';
+ const { body: rule } = await detectionsApi
+ .createRule({ body: getCustomQueryRuleParams({ rule_id: ruleId }) })
+ .expect(200);
+
+ const ndjson = combineToNdJson(
+ getCustomQueryRuleParams({ rule_id: ruleId, name: 'overwritten name' })
+ );
+
+ await detectionsApi
+ .importRules({ query: { overwrite: true } })
+ .attach('file', Buffer.from(ndjson), 'rules.ndjson')
+ .expect(200);
+
+ await refreshHistory();
+
+ const { body } = await detectionsApi
+ .ruleChangesHistory({ params: { ruleId: rule.id }, query: {} })
+ .expect(200);
+
+ // Most recent event (index 0) is the import overwrite; index 1 is the original create.
+ expect(body.items[0].action).toBe('rule_import');
+ });
+
+ it('records rule_install when installing a prebuilt rule', async () => {
+ const ruleId = 'install-action-test-rule';
+ await createPrebuiltRuleAssetSavedObjects(es, [
+ createRuleAssetSavedObject({ rule_id: ruleId, version: 1 }),
+ ]);
+
+ await installPrebuiltRules(es, supertest);
+
+ const { body: rule } = await detectionsApi.readRule({ query: { rule_id: ruleId } });
+ await refreshHistory();
+
+ const { body } = await detectionsApi
+ .ruleChangesHistory({ params: { ruleId: rule.id }, query: {} })
+ .expect(200);
+
+ expect(body.items).toHaveLength(1);
+ expect(body.items[0].action).toBe('rule_install');
+ });
+
+ it('records rule_upgrade when upgrading a prebuilt rule', async () => {
+ const ruleId = 'upgrade-action-test-rule';
+ await createHistoricalPrebuiltRuleAssetSavedObjects(es, [
+ createRuleAssetSavedObject({ rule_id: ruleId, version: 1 }),
+ ]);
+
+ await installPrebuiltRules(es, supertest);
+
+ await createHistoricalPrebuiltRuleAssetSavedObjects(es, [
+ createRuleAssetSavedObject({ rule_id: ruleId, version: 2 }),
+ ]);
+
+ await performUpgradePrebuiltRules(es, supertest, {
+ mode: ModeEnum.ALL_RULES,
+ pick_version: 'TARGET',
+ });
+
+ const { body: rule } = await detectionsApi.readRule({ query: { rule_id: ruleId } });
+ await refreshHistory();
+
+ const { body } = await detectionsApi
+ .ruleChangesHistory({ params: { ruleId: rule.id }, query: {} })
+ .expect(200);
+
+ // Most recent event (index 0) is the upgrade; index 1 is the original install.
+ expect(body.items[0].action).toBe('rule_upgrade');
+ });
+
+ it('records rule_duplicate when duplicating a rule', async () => {
+ const { body: original } = await detectionsApi
+ .createRule({ body: getCustomQueryRuleParams() })
+ .expect(200);
+
+ const { body: bulkResponse } = await detectionsApi
+ .performRulesBulkAction({
+ query: {},
+ body: {
+ action: BulkActionTypeEnum.duplicate,
+ duplicate: { include_exceptions: false, include_expired_exceptions: false },
+ },
+ })
+ .expect(200);
+
+ const duplicatedRuleId = bulkResponse.attributes.results.created[0].id;
+
+ await refreshHistory();
+
+ const { body } = await detectionsApi
+ .ruleChangesHistory({ params: { ruleId: duplicatedRuleId }, query: {} })
+ .expect(200);
+
+ expect(body.items).toHaveLength(1);
+ expect(body.items[0].action).toBe('rule_duplicate');
+ expect(body.items[0].metadata?.originalRuleSoId).toBe(original.id);
+ });
+
+ it('records rule_revert when reverting a prebuilt rule', async () => {
+ const ruleId = 'revert-action-test-rule';
+ await createPrebuiltRuleAssetSavedObjects(es, [
+ createRuleAssetSavedObject({ rule_id: ruleId, version: 1 }),
+ ]);
+
+ await installPrebuiltRules(es, supertest);
+
+ const { body: customized } = await detectionsApi
+ .patchRule({ body: { rule_id: ruleId, name: 'customized name' } })
+ .expect(200);
+
+ await revertPrebuiltRule(supertest, {
+ id: customized.id,
+ version: customized.version,
+ revision: customized.revision,
+ });
+
+ const { body: rule } = await detectionsApi.readRule({ query: { rule_id: ruleId } });
+ await refreshHistory();
+
+ const { body } = await detectionsApi
+ .ruleChangesHistory({ params: { ruleId: rule.id }, query: {} })
+ .expect(200);
+
+ // Most recent event (index 0) is the revert.
+ expect(body.items[0].action).toBe('rule_revert');
+ });
+ });
+
+ describe('metadata.bulkCount', () => {
+ it('records bulkCount equal to the number of imported rules', async () => {
+ const ndjson = combineToNdJson(
+ getCustomQueryRuleParams({ rule_id: 'bulk-import-count-1' }),
+ getCustomQueryRuleParams({ rule_id: 'bulk-import-count-2' }),
+ getCustomQueryRuleParams({ rule_id: 'bulk-import-count-3' })
+ );
+
+ await supertest
+ .post(`${DETECTION_ENGINE_RULES_IMPORT_URL}?overwrite=true`)
+ .set('kbn-xsrf', 'true')
+ .set('elastic-api-version', '2023-10-31')
+ .attach('file', Buffer.from(ndjson), 'rules.ndjson')
+ .expect(200);
+
+ const { body: rule } = await detectionsApi.readRule({
+ query: { rule_id: 'bulk-import-count-1' },
+ });
+ await refreshHistory();
+
+ const { body } = await detectionsApi
+ .ruleChangesHistory({ params: { ruleId: rule.id }, query: {} })
+ .expect(200);
+
+ expect(body.items[0].metadata?.bulkCount).toBe(3);
+ });
+
+ it('records bulkCount equal to the number of installed prebuilt rules', async () => {
+ await createPrebuiltRuleAssetSavedObjects(es, [
+ createRuleAssetSavedObject({ rule_id: 'bulk-install-count-1', version: 1 }),
+ createRuleAssetSavedObject({ rule_id: 'bulk-install-count-2', version: 1 }),
+ ]);
+
+ await installPrebuiltRules(es, supertest);
+
+ const { body: rule } = await detectionsApi.readRule({
+ query: { rule_id: 'bulk-install-count-1' },
+ });
+ await refreshHistory();
+
+ const { body } = await detectionsApi
+ .ruleChangesHistory({ params: { ruleId: rule.id }, query: {} })
+ .expect(200);
+
+ expect(body.items[0].metadata?.bulkCount).toBe(2);
+ });
+
+ it('records bulkCount equal to the number of upgraded prebuilt rules', async () => {
+ await createHistoricalPrebuiltRuleAssetSavedObjects(es, [
+ createRuleAssetSavedObject({ rule_id: 'bulk-upgrade-count-1', version: 1 }),
+ createRuleAssetSavedObject({ rule_id: 'bulk-upgrade-count-2', version: 1 }),
+ ]);
+
+ await installPrebuiltRules(es, supertest);
+
+ await createHistoricalPrebuiltRuleAssetSavedObjects(es, [
+ createRuleAssetSavedObject({ rule_id: 'bulk-upgrade-count-1', version: 2 }),
+ createRuleAssetSavedObject({ rule_id: 'bulk-upgrade-count-2', version: 2 }),
+ ]);
+
+ await performUpgradePrebuiltRules(es, supertest, {
+ mode: ModeEnum.ALL_RULES,
+ pick_version: 'TARGET',
+ });
+
+ const { body: rule } = await detectionsApi.readRule({
+ query: { rule_id: 'bulk-upgrade-count-1' },
+ });
+ await refreshHistory();
+
+ const { body } = await detectionsApi
+ .ruleChangesHistory({ params: { ruleId: rule.id }, query: {} })
+ .expect(200);
+
+ // Most recent event (index 0) is the upgrade with bulkCount = 2.
+ expect(body.items[0].metadata?.bulkCount).toBe(2);
+ });
+ });
+ });
+};
diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_management/trial_license_complete_tier/index.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_management/trial_license_complete_tier/index.ts
index 46e4497a1852e..0621fa61391a3 100644
--- a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_management/trial_license_complete_tier/index.ts
+++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_management/trial_license_complete_tier/index.ts
@@ -11,6 +11,6 @@ export default function ({ loadTestFile }: FtrProviderContext) {
describe('Rules Management - Rule Management API', function () {
loadTestFile(require.resolve('./read_rule_execution_results'));
loadTestFile(require.resolve('./get_rule_management_filters'));
- loadTestFile(require.resolve('./rule_history'));
+ loadTestFile(require.resolve('./change_tracking'));
});
}
diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_management/trial_license_complete_tier/rule_history.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_management/trial_license_complete_tier/rule_history.ts
deleted file mode 100644
index a5389b28289ff..0000000000000
--- a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_management/trial_license_complete_tier/rule_history.ts
+++ /dev/null
@@ -1,233 +0,0 @@
-/*
- * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
- * or more contributor license agreements. Licensed under the Elastic License
- * 2.0; you may not use this file except in compliance with the Elastic License
- * 2.0.
- */
-
-import expect from 'expect';
-import { v4 as uuidv4 } from 'uuid';
-import { deleteAllRules } from '@kbn/detections-response-ftr-services';
-import { getCustomQueryRuleParams } from '../../../utils';
-import type { FtrProviderContext } from '../../../../../ftr_provider_context';
-
-const CHANGE_HISTORY_DATA_STREAM = '.kibana_change_history';
-
-export default ({ getService }: FtrProviderContext): void => {
- const supertest = getService('supertest');
- const detectionsApi = getService('detectionsApi');
- const es = getService('es');
- const log = getService('log');
-
- const refreshHistory = async () => {
- await es.indices.refresh({ index: CHANGE_HISTORY_DATA_STREAM });
- };
-
- // Skipped until a feature flag in @kbn/change-history package is enabled
- describe.skip('@ess @serverless @serverlessQA rule history API', () => {
- beforeEach(async () => {
- await deleteAllRules(supertest, log);
- try {
- await es.deleteByQuery({
- index: CHANGE_HISTORY_DATA_STREAM,
- query: { match_all: {} },
- conflicts: 'proceed',
- refresh: true,
- });
- } catch {
- // Change history index may not exist
- }
- });
-
- it('returns the rule_create record for a newly-created rule', async () => {
- const { body: rule } = await detectionsApi
- .createRule({
- body: getCustomQueryRuleParams(),
- })
- .expect(200);
-
- await refreshHistory();
-
- const { body } = await detectionsApi
- .ruleChangesHistory({ params: { ruleId: rule.id }, query: {} })
- .expect(200);
-
- expect(body.page).toBe(1);
- expect(body.perPage).toBe(20);
- expect(body.total).toBe(1);
- expect(body.items).toHaveLength(1);
-
- const [item] = body.items;
- expect(item.action).toBe('rule_create');
- expect(item.user).toEqual({ name: 'elastic' });
- expect(item.rule).toMatchObject({ id: rule.id, revision: 0 });
- // No predecessor for the creation event.
- expect(item.old_values).toBeNull();
- });
-
- it('returns 404 when the rule does not exist', async () => {
- await detectionsApi
- .ruleChangesHistory({ params: { ruleId: uuidv4() }, query: {} })
- .expect(404);
- });
-
- it('rejects the request when the "ruleId" path parameter is missing', async () => {
- // @ts-expect-error testing missing id
- await detectionsApi.ruleChangesHistory({ params: {}, query: {} }).expect(400);
- });
-
- it('rejects the request when per_page exceeds the maximum', async () => {
- const { body: rule } = await detectionsApi
- .createRule({
- body: getCustomQueryRuleParams(),
- })
- .expect(200);
-
- await detectionsApi
- .ruleChangesHistory({ params: { ruleId: rule.id }, query: { per_page: 101 } })
- .expect(400);
- });
-
- describe('pagination', () => {
- let ruleId: string;
-
- beforeEach(async () => {
- // Create the rule (revision 0, rule_create) and update it four times
- // (revisions 1-4, rule_update) so there are 5 history records.
- const { body: rule } = await detectionsApi
- .createRule({
- body: getCustomQueryRuleParams(),
- })
- .expect(200);
-
- ruleId = rule.id;
-
- for (let i = 1; i <= 4; i++) {
- await detectionsApi
- .updateRule({
- body: getCustomQueryRuleParams({ rule_id: rule.rule_id, name: `name-${i}` }),
- })
- .expect(200);
- }
-
- await refreshHistory();
- });
-
- it('returns the requested page with the right size and total', async () => {
- const { body } = await detectionsApi
- .ruleChangesHistory({ params: { ruleId }, query: { page: 1, per_page: 2 } })
- .expect(200);
-
- expect(body.total).toBe(5);
- expect(body.page).toBe(1);
- expect(body.perPage).toBe(2);
- expect(body.items).toHaveLength(2);
- expect(body.items[0].rule.revision).toBe(4);
- expect(body.items[1].rule.revision).toBe(3);
- });
-
- it('returns subsequent pages without overlap', async () => {
- const { body: page2 } = await detectionsApi
- .ruleChangesHistory({ params: { ruleId }, query: { page: 2, per_page: 2 } })
- .expect(200);
-
- expect(page2.items).toHaveLength(2);
- expect(page2.items[0].rule.revision).toBe(2);
- expect(page2.items[1].rule.revision).toBe(1);
- });
-
- it('returns fewer items on the last partial page', async () => {
- const { body: page3 } = await detectionsApi
- .ruleChangesHistory({ params: { ruleId }, query: { page: 3, per_page: 2 } })
- .expect(200);
-
- expect(page3.items).toHaveLength(1);
- expect(page3.items[0].rule.revision).toBe(0);
- // Creation event has no predecessor.
- expect(page3.items[0].old_values).toBeNull();
- });
-
- it('computes `old_values` against the next-older revision across page boundaries', async () => {
- // Oldest item on page 1 (revision 3) should still see revision 2 as
- // its predecessor — provided by the perPage+1 lookback fetch.
- const { body: page1 } = await detectionsApi
- .ruleChangesHistory({ params: { ruleId }, query: { page: 1, per_page: 2 } })
- .expect(200);
-
- expect(page1.items[1].old_values).not.toBeNull();
- expect(page1.items[1].old_values.revision).toBe(2);
- });
- });
-
- describe('field-level changes (old_values merge patch)', () => {
- it('emits only the changed top-level field in `old_values`', async () => {
- const { body: rule } = await detectionsApi
- .createRule({
- body: getCustomQueryRuleParams({ name: 'name-A' }),
- })
- .expect(200);
-
- await detectionsApi
- .updateRule({
- body: getCustomQueryRuleParams({ rule_id: rule.rule_id, name: 'name-B' }),
- })
- .expect(200);
-
- await refreshHistory();
-
- const { body } = await detectionsApi
- .ruleChangesHistory({ params: { ruleId: rule.id }, query: {} })
- .expect(200);
-
- expect(body.total).toBe(2);
- // Newest (revision 1, name='name-B') first; old_values reflects the
- // single field that differs from revision 0.
- expect(body.items[0].rule.name).toBe('name-B');
- expect(body.items[0].old_values?.name).toBe('name-A');
- });
-
- it('emits multiple changed fields in a single patch', async () => {
- const { body: rule } = await detectionsApi
- .createRule({
- body: getCustomQueryRuleParams({ name: 'name-A', tags: ['x'] }),
- })
- .expect(200);
-
- await detectionsApi
- .updateRule({
- body: getCustomQueryRuleParams({
- rule_id: rule.rule_id,
- name: 'name-B',
- tags: ['x', 'y'],
- }),
- })
- .expect(200);
-
- await refreshHistory();
-
- const { body } = await detectionsApi
- .ruleChangesHistory({ params: { ruleId: rule.id }, query: {} })
- .expect(200);
-
- expect(body.items[0].old_values?.name).toBe('name-A');
- expect(body.items[0].old_values?.tags).toEqual(['x']);
- });
-
- it('returns null `old_values` for the creation event', async () => {
- const { body: rule } = await detectionsApi
- .createRule({
- body: getCustomQueryRuleParams(),
- })
- .expect(200);
-
- await refreshHistory();
-
- const { body } = await detectionsApi
- .ruleChangesHistory({ params: { ruleId: rule.id }, query: {} })
- .expect(200);
-
- expect(body.items[0].old_values).toBeNull();
- });
- });
- });
-};
From 03fa567ff82d99217ce3021e30a6b464285ce463 Mon Sep 17 00:00:00 2001
From: Sean Story
Date: Wed, 27 May 2026 09:36:16 -0500
Subject: [PATCH 038/193] [EARS] Make EARS feature-flag gate functionality
per-provider (#270426)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
Closes https://github.com/elastic/search-team/issues/14522
Adds per-provider EARS feature flagging via a two-tier system:
- **Stable providers** (Microsoft, Slack): enabled whenever
`xpack.actions.auth.ears.enabled: true`
- **Experimental providers** (Google): only enabled when *both*
`ears.enabled: true` **and** `ears.enableExperimental: true`
This allows us to ship EARS for verified OAuth providers while keeping
unverified ones (Google, pending app verification) available only for
internal dogfooding.
### How it works
- Each connector spec's EARS auth type entry can declare `experimental:
true` (Google Calendar, Gmail, Google Drive do this)
- A new `xpack.actions.auth.ears.enableExperimental` boolean config
controls whether experimental EARS providers are available
- The filtering happens at schema generation time
(`generateSecretsSchemaFromSpec`), so both the UI and API are gated
- Existing EARS connectors for experimental providers show as disabled
in the connectors table when `enableExperimental` is off
### Promotion flow
When Google's OAuth app verification completes:
1. Remove `experimental: true` from the 3 Google specs (one-line diff
each)
2. **No deployment config changes needed** — Google EARS "just works"
for everyone
### Config
```yaml
# kibana.yml
xpack.actions.auth.ears:
enabled: true # global EARS gate (existing)
enableExperimental: true # opt-in for unverified providers (new)
```
### Changes
| Area | What |
|------|------|
| `kbn-connector-specs` | `AuthTypeDef.experimental` flag, filtering in
`generateSecretsSchemaFromSpec`, `isEarsExperimentalConnector` helper |
| `actions` plugin | `ears.enableExperimental` config,
`isEarsExperimentalEnabled()` utility, exposed to browser |
| `stack_connectors` | Thread `isEarsExperimentalEnabled` through
client-side schema generation |
| `agent_builder` | Per-provider disabled check in connectors table |
| `triggers_actions_ui` | Per-provider disabled check in connectors list
|
| Google specs | `experimental: true` on EARS auth type
(google_calendar, gmail, google_drive) |
## Test plan
- [ ] With `ears.enabled: true` and no `enableExperimental`:
Microsoft/Slack connectors show EARS option, Google connectors do not
- [ ] With `ears.enabled: true` and `enableExperimental: true`: all
connectors show EARS option
- [ ] With `ears.enabled: false`: no connectors show EARS option
regardless of `enableExperimental`
- [ ] Creating a Google EARS connector via API fails when
`enableExperimental` is off
- [ ] Previously created Google EARS connectors show as disabled when
`enableExperimental` is turned off
- [ ] Unit tests pass: `node scripts/jest
src/platform/packages/shared/kbn-connector-specs/`
- [ ] Unit tests pass: `node scripts/jest
x-pack/platform/plugins/shared/actions/server/`
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Sonnet 4.6
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
---
.github/CODEOWNERS | 4 +
.../shared/kbn-connector-specs/index.ts | 1 +
.../kbn-connector-specs/src/connector_spec.ts | 1 +
.../src/lib/ears_experimental_utils.test.ts | 34 +++++++
.../src/lib/ears_experimental_utils.ts | 27 ++++++
.../generate_secrets_schema_from_spec.test.ts | 91 +++++++++++++++++++
.../lib/generate_secrets_schema_from_spec.ts | 14 ++-
.../src/lib/serialize_connector_spec.test.ts | 62 ++++++++++++-
.../src/lib/serialize_connector_spec.ts | 1 +
.../src/specs/gmail/gmail.ts | 1 +
.../specs/google_calendar/google_calendar.ts | 1 +
.../src/specs/google_drive/google_drive.ts | 1 +
.../plugins/shared/actions/public/plugin.ts | 5 +
.../actions/server/actions_config.mock.ts | 1 +
.../actions/server/actions_config.test.ts | 24 ++++-
.../shared/actions/server/actions_config.ts | 2 +
.../get_connector_spec.test.ts | 79 ++++++++++++++++
.../get_connector_spec/get_connector_spec.ts | 7 +-
.../plugins/shared/actions/server/config.ts | 1 +
.../plugins/shared/actions/server/index.ts | 2 +-
.../generate_secrets_schema.test.ts | 55 +++++++++++
.../generate_secrets_schema.ts | 25 ++++-
.../connectors/table/connectors_table.tsx | 12 ++-
.../table/connectors_table_columns.tsx | 9 +-
.../table/connectors_table_context_menu.tsx | 24 +++--
.../shared/agent_builder/public/plugin.tsx | 3 +
.../agent_builder/public/services/types.ts | 1 +
.../generate_schema.ts | 15 ++-
.../register_from_spec.test.ts | 7 ++
.../register_from_spec.ts | 34 +++++--
.../shared/stack_connectors/public/plugin.ts | 1 +
.../shared/triggers_actions_ui/moon.yml | 1 +
.../components/actions_connectors_list.tsx | 17 ++--
.../common/lib/kibana/kibana_react.mock.ts | 1 +
.../shared/triggers_actions_ui/tsconfig.json | 3 +-
35 files changed, 527 insertions(+), 40 deletions(-)
create mode 100644 src/platform/packages/shared/kbn-connector-specs/src/lib/ears_experimental_utils.test.ts
create mode 100644 src/platform/packages/shared/kbn-connector-specs/src/lib/ears_experimental_utils.ts
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index ea2f20428bc4c..25189883bdb1b 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -2534,6 +2534,10 @@ x-pack/platform/plugins/shared/actions/server/lib/ears @elastic/workchat-eng
x-pack/platform/plugins/shared/actions/server/lib/axios_auth_strategies/ears_strategy.ts @elastic/workchat-eng @elastic/response-ops
x-pack/platform/plugins/shared/actions/server/lib/axios_auth_strategies/ears_strategy.test.ts @elastic/workchat-eng @elastic/response-ops
src/platform/packages/shared/kbn-connector-specs/src/auth_types/ears.ts @elastic/workchat-eng
+src/platform/packages/shared/kbn-connector-specs/src/lib/ears_experimental_utils.ts @elastic/workchat-eng
+src/platform/packages/shared/kbn-connector-specs/src/lib/ears_experimental_utils.test.ts @elastic/workchat-eng
+src/platform/packages/shared/kbn-connector-specs/src/lib/generate_secrets_schema_from_spec.ts @elastic/workchat-eng @elastic/response-ops
+src/platform/packages/shared/kbn-connector-specs/src/lib/generate_secrets_schema_from_spec.test.ts @elastic/workchat-eng @elastic/response-ops
# Connector Specs
diff --git a/src/platform/packages/shared/kbn-connector-specs/index.ts b/src/platform/packages/shared/kbn-connector-specs/index.ts
index cea78ad8aa575..05cedb86ba97f 100644
--- a/src/platform/packages/shared/kbn-connector-specs/index.ts
+++ b/src/platform/packages/shared/kbn-connector-specs/index.ts
@@ -34,6 +34,7 @@ export {
ESTIMATED_JSON_OUTPUT_OVERHEAD_BYTES,
} from './src/connector_utils';
export { normalizeAuthorizationHeaderValue } from './src/auth_types/oauth_authz_code_and_ears_helpers';
+export { isEarsExperimentalConnector } from './src/lib/ears_experimental_utils';
export { ConnectorAuthorizationError, isConnectorAuthorizationError } from './src/errors';
export type { ConnectorAuthorizationReason } from './src/errors';
diff --git a/src/platform/packages/shared/kbn-connector-specs/src/connector_spec.ts b/src/platform/packages/shared/kbn-connector-specs/src/connector_spec.ts
index e62ccde916b0b..883c014ae713d 100644
--- a/src/platform/packages/shared/kbn-connector-specs/src/connector_spec.ts
+++ b/src/platform/packages/shared/kbn-connector-specs/src/connector_spec.ts
@@ -267,6 +267,7 @@ export interface ConnectorTest {
export interface AuthTypeDef {
type: string;
+ isExperimental?: boolean;
defaults: Record;
overrides?: {
meta?: Record>;
diff --git a/src/platform/packages/shared/kbn-connector-specs/src/lib/ears_experimental_utils.test.ts b/src/platform/packages/shared/kbn-connector-specs/src/lib/ears_experimental_utils.test.ts
new file mode 100644
index 0000000000000..13e8359dc23a8
--- /dev/null
+++ b/src/platform/packages/shared/kbn-connector-specs/src/lib/ears_experimental_utils.test.ts
@@ -0,0 +1,34 @@
+/*
+ * 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+import { isEarsExperimentalConnector } from './ears_experimental_utils';
+
+describe('isEarsExperimentalConnector', () => {
+ test('returns true for connector types whose EARS auth is marked experimental', () => {
+ // Google connectors have experimental: true on their EARS auth type
+ expect(isEarsExperimentalConnector('.google_calendar')).toBe(true);
+ expect(isEarsExperimentalConnector('.gmail')).toBe(true);
+ expect(isEarsExperimentalConnector('.google_drive')).toBe(true);
+ });
+
+ test('returns false for connector types whose EARS auth is stable', () => {
+ // Microsoft and Slack connectors have EARS without experimental flag
+ expect(isEarsExperimentalConnector('.microsoft_teams')).toBe(false);
+ expect(isEarsExperimentalConnector('.slack')).toBe(false);
+ expect(isEarsExperimentalConnector('.sharepoint_online')).toBe(false);
+ });
+
+ test('returns false for connector types with no EARS auth', () => {
+ expect(isEarsExperimentalConnector('.alienvault-otx')).toBe(false);
+ });
+
+ test('returns false for unknown connector types', () => {
+ expect(isEarsExperimentalConnector('.nonexistent')).toBe(false);
+ });
+});
diff --git a/src/platform/packages/shared/kbn-connector-specs/src/lib/ears_experimental_utils.ts b/src/platform/packages/shared/kbn-connector-specs/src/lib/ears_experimental_utils.ts
new file mode 100644
index 0000000000000..a5aa54cfefde1
--- /dev/null
+++ b/src/platform/packages/shared/kbn-connector-specs/src/lib/ears_experimental_utils.ts
@@ -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", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+import { isString } from 'lodash';
+import * as allSpecs from '../all_specs';
+import { EARS_AUTH_ID } from '../auth_types/ears';
+import type { AuthTypeDef } from '../connector_spec';
+
+export const isEarsExperimentalAuthType = (
+ authType: string | AuthTypeDef
+): authType is AuthTypeDef =>
+ !isString(authType) && authType.type === EARS_AUTH_ID && authType.isExperimental === true;
+
+const experimentalEarsConnectorIds = new Set(
+ Object.values(allSpecs)
+ .filter((spec) => spec.auth?.types.some(isEarsExperimentalAuthType))
+ .map((spec) => spec.metadata.id)
+);
+
+export const isEarsExperimentalConnector = (connectorTypeId: string): boolean =>
+ experimentalEarsConnectorIds.has(connectorTypeId);
diff --git a/src/platform/packages/shared/kbn-connector-specs/src/lib/generate_secrets_schema_from_spec.test.ts b/src/platform/packages/shared/kbn-connector-specs/src/lib/generate_secrets_schema_from_spec.test.ts
index 082f3e9aec5bc..b8d3dd5d28a6b 100644
--- a/src/platform/packages/shared/kbn-connector-specs/src/lib/generate_secrets_schema_from_spec.test.ts
+++ b/src/platform/packages/shared/kbn-connector-specs/src/lib/generate_secrets_schema_from_spec.test.ts
@@ -126,6 +126,97 @@ describe('generateSecretsSchemaFromSpec', () => {
expect(authTypes).toContain('oauth_authorization_code');
});
+ describe('experimental EARS filtering', () => {
+ const authSpecWithExperimentalEars = {
+ types: [
+ 'bearer',
+ {
+ type: 'ears',
+ isExperimental: true,
+ defaults: { provider: 'google', scope: 'https://www.googleapis.com/auth/calendar' },
+ },
+ ],
+ };
+
+ const authSpecWithStableEars = {
+ types: [
+ 'bearer',
+ {
+ type: 'ears',
+ defaults: { provider: 'slack', scope: 'channels:read' },
+ },
+ ],
+ };
+
+ test('excludes experimental EARS when isEarsEnabled but isEarsExperimentalEnabled is false', () => {
+ const schema = generateSecretsSchemaFromSpec(authSpecWithExperimentalEars, {
+ isEarsEnabled: true,
+ isEarsExperimentalEnabled: false,
+ });
+ const jsonSchema = z.toJSONSchema(schema) as {
+ oneOf?: Array<{ properties?: { authType?: { const?: string } } }>;
+ };
+ const oneOfOptions = jsonSchema.oneOf || [];
+ const authTypes = oneOfOptions
+ .map((opt) => opt.properties?.authType?.const)
+ .filter(Boolean) as string[];
+
+ expect(authTypes).toContain('bearer');
+ expect(authTypes).not.toContain('ears');
+ });
+
+ test('includes experimental EARS when both isEarsEnabled and isEarsExperimentalEnabled are true', () => {
+ const schema = generateSecretsSchemaFromSpec(authSpecWithExperimentalEars, {
+ isEarsEnabled: true,
+ isEarsExperimentalEnabled: true,
+ });
+ const jsonSchema = z.toJSONSchema(schema) as {
+ oneOf?: Array<{ properties?: { authType?: { const?: string } } }>;
+ };
+ const oneOfOptions = jsonSchema.oneOf || [];
+ const authTypes = oneOfOptions
+ .map((opt) => opt.properties?.authType?.const)
+ .filter(Boolean) as string[];
+
+ expect(authTypes).toContain('bearer');
+ expect(authTypes).toContain('ears');
+ });
+
+ test('includes stable EARS when isEarsEnabled is true regardless of isEarsExperimentalEnabled', () => {
+ const schema = generateSecretsSchemaFromSpec(authSpecWithStableEars, {
+ isEarsEnabled: true,
+ isEarsExperimentalEnabled: false,
+ });
+ const jsonSchema = z.toJSONSchema(schema) as {
+ oneOf?: Array<{ properties?: { authType?: { const?: string } } }>;
+ };
+ const oneOfOptions = jsonSchema.oneOf || [];
+ const authTypes = oneOfOptions
+ .map((opt) => opt.properties?.authType?.const)
+ .filter(Boolean) as string[];
+
+ expect(authTypes).toContain('bearer');
+ expect(authTypes).toContain('ears');
+ });
+
+ test('excludes all EARS when isEarsEnabled is false even if isEarsExperimentalEnabled is true', () => {
+ const schema = generateSecretsSchemaFromSpec(authSpecWithExperimentalEars, {
+ isEarsEnabled: false,
+ isEarsExperimentalEnabled: true,
+ });
+ const jsonSchema = z.toJSONSchema(schema) as {
+ oneOf?: Array<{ properties?: { authType?: { const?: string } } }>;
+ };
+ const oneOfOptions = jsonSchema.oneOf || [];
+ const authTypes = oneOfOptions
+ .map((opt) => opt.properties?.authType?.const)
+ .filter(Boolean) as string[];
+
+ expect(authTypes).toContain('bearer');
+ expect(authTypes).not.toContain('ears');
+ });
+ });
+
describe('runtime parse behavior', () => {
test('parses valid secrets for none auth type', () => {
const schema = generateSecretsSchemaFromSpec({ types: ['none'] });
diff --git a/src/platform/packages/shared/kbn-connector-specs/src/lib/generate_secrets_schema_from_spec.ts b/src/platform/packages/shared/kbn-connector-specs/src/lib/generate_secrets_schema_from_spec.ts
index 4cb432f05c666..e3eccab691a1c 100644
--- a/src/platform/packages/shared/kbn-connector-specs/src/lib/generate_secrets_schema_from_spec.ts
+++ b/src/platform/packages/shared/kbn-connector-specs/src/lib/generate_secrets_schema_from_spec.ts
@@ -11,18 +11,21 @@ import { z } from '@kbn/zod/v4';
import type { AuthMode, ConnectorSpec } from '../connector_spec';
import * as authTypeSpecs from '../all_auth_types';
import { getSchemaForAuthType } from '.';
+import { isEarsExperimentalAuthType } from './ears_experimental_utils';
interface GenerateOptions {
isPfxEnabled?: boolean;
isEarsEnabled?: boolean;
+ isEarsExperimentalEnabled?: boolean;
authMode?: AuthMode | '';
}
export const generateSecretsSchemaFromSpec = (
authSpec: ConnectorSpec['auth'],
- { isPfxEnabled, isEarsEnabled, authMode }: GenerateOptions = {
+ { isPfxEnabled, isEarsEnabled, isEarsExperimentalEnabled, authMode }: GenerateOptions = {
isPfxEnabled: true,
isEarsEnabled: false,
+ isEarsExperimentalEnabled: false,
}
) => {
const secretSchemas: z.core.$ZodTypeDiscriminable[] = [];
@@ -31,8 +34,13 @@ export const generateSecretsSchemaFromSpec = (
if (schema.id === 'pfx_certificate' && !isPfxEnabled) {
continue;
}
- if (schema.id === 'ears' && !isEarsEnabled) {
- continue;
+ if (schema.id === 'ears') {
+ if (!isEarsEnabled) {
+ continue;
+ }
+ if (isEarsExperimentalAuthType(authType) && !isEarsExperimentalEnabled) {
+ continue;
+ }
}
const authTypeSpec = Object.values(authTypeSpecs).find((spec) => spec.id === schema.id);
diff --git a/src/platform/packages/shared/kbn-connector-specs/src/lib/serialize_connector_spec.test.ts b/src/platform/packages/shared/kbn-connector-specs/src/lib/serialize_connector_spec.test.ts
index e96ad37320e19..a6088ce9489d5 100644
--- a/src/platform/packages/shared/kbn-connector-specs/src/lib/serialize_connector_spec.test.ts
+++ b/src/platform/packages/shared/kbn-connector-specs/src/lib/serialize_connector_spec.test.ts
@@ -267,11 +267,16 @@ describe('serializeConnectorSpec', () => {
const spy = jest.spyOn(generateSecretsModule, 'generateSecretsSchemaFromSpec');
- serializeConnectorSpec(spec, { isPfxEnabled: false, isEarsEnabled: false });
+ serializeConnectorSpec(spec, {
+ isPfxEnabled: false,
+ isEarsEnabled: false,
+ isEarsExperimentalEnabled: false,
+ });
expect(spy).toHaveBeenCalledWith(spec.auth, {
isPfxEnabled: false,
isEarsEnabled: false,
+ isEarsExperimentalEnabled: false,
});
spy.mockRestore();
@@ -298,7 +303,11 @@ describe('serializeConnectorSpec', () => {
};
const defaultEars = serializeConnectorSpec(spec);
- const earsOn = serializeConnectorSpec(spec, { isPfxEnabled: true, isEarsEnabled: true });
+ const earsOn = serializeConnectorSpec(spec, {
+ isPfxEnabled: true,
+ isEarsEnabled: true,
+ isEarsExperimentalEnabled: false,
+ });
interface SecretBranch {
properties?: { authType?: { const?: string } };
}
@@ -392,4 +401,53 @@ describe('serializeConnectorSpec', () => {
}
});
});
+
+ describe('experimental EARS filtering', () => {
+ test('excludes experimental EARS auth when isEarsExperimentalEnabled is false', () => {
+ const testSpec = {
+ metadata: {
+ id: '.test-experimental-ears',
+ displayName: 'Test',
+ description: 'Test connector',
+ minimumLicense: 'basic' as const,
+ supportedFeatureIds: ['alerting' as const],
+ },
+ auth: {
+ types: [
+ 'bearer',
+ {
+ type: 'ears',
+ isExperimental: true,
+ defaults: { provider: 'google', scope: 'test-scope' },
+ },
+ ],
+ },
+ actions: {
+ test: {
+ input: z.object({}),
+ handler: async () => ({ success: true }),
+ },
+ },
+ };
+
+ const result = serializeConnectorSpec(testSpec, {
+ isPfxEnabled: true,
+ isEarsEnabled: true,
+ isEarsExperimentalEnabled: false,
+ });
+
+ const schemaJson = result.schema as {
+ properties?: {
+ secrets?: { oneOf?: Array<{ properties?: { authType?: { const?: string } } }> };
+ };
+ };
+ const secretsOneOf = schemaJson.properties?.secrets?.oneOf || [];
+ const authTypes = secretsOneOf
+ .map((opt) => opt.properties?.authType?.const)
+ .filter(Boolean) as string[];
+
+ expect(authTypes).toContain('bearer');
+ expect(authTypes).not.toContain('ears');
+ });
+ });
});
diff --git a/src/platform/packages/shared/kbn-connector-specs/src/lib/serialize_connector_spec.ts b/src/platform/packages/shared/kbn-connector-specs/src/lib/serialize_connector_spec.ts
index 05465d8fa801d..155b8120be332 100644
--- a/src/platform/packages/shared/kbn-connector-specs/src/lib/serialize_connector_spec.ts
+++ b/src/platform/packages/shared/kbn-connector-specs/src/lib/serialize_connector_spec.ts
@@ -14,6 +14,7 @@ import { generateSecretsSchemaFromSpec } from './generate_secrets_schema_from_sp
export interface SerializeConnectorSpecOptions {
isPfxEnabled: boolean;
isEarsEnabled: boolean;
+ isEarsExperimentalEnabled: boolean;
}
export function serializeConnectorSpec(
diff --git a/src/platform/packages/shared/kbn-connector-specs/src/specs/gmail/gmail.ts b/src/platform/packages/shared/kbn-connector-specs/src/specs/gmail/gmail.ts
index b13763fc4f029..4bbe730697fae 100644
--- a/src/platform/packages/shared/kbn-connector-specs/src/specs/gmail/gmail.ts
+++ b/src/platform/packages/shared/kbn-connector-specs/src/specs/gmail/gmail.ts
@@ -58,6 +58,7 @@ export const GmailConnector: ConnectorSpec = {
},
{
type: 'ears',
+ isExperimental: true,
overrides: {
meta: { scope: { disabled: true } },
},
diff --git a/src/platform/packages/shared/kbn-connector-specs/src/specs/google_calendar/google_calendar.ts b/src/platform/packages/shared/kbn-connector-specs/src/specs/google_calendar/google_calendar.ts
index f1ff7e4703825..9a876af4ddfcf 100644
--- a/src/platform/packages/shared/kbn-connector-specs/src/specs/google_calendar/google_calendar.ts
+++ b/src/platform/packages/shared/kbn-connector-specs/src/specs/google_calendar/google_calendar.ts
@@ -69,6 +69,7 @@ export const GoogleCalendar: ConnectorSpec = {
},
{
type: 'ears',
+ isExperimental: true,
overrides: {
meta: { scope: { disabled: true } },
},
diff --git a/src/platform/packages/shared/kbn-connector-specs/src/specs/google_drive/google_drive.ts b/src/platform/packages/shared/kbn-connector-specs/src/specs/google_drive/google_drive.ts
index 6bf9e588fb3c5..f094827b79f94 100644
--- a/src/platform/packages/shared/kbn-connector-specs/src/specs/google_drive/google_drive.ts
+++ b/src/platform/packages/shared/kbn-connector-specs/src/specs/google_drive/google_drive.ts
@@ -93,6 +93,7 @@ export const GoogleDriveConnector: ConnectorSpec = {
},
{
type: 'ears',
+ isExperimental: true,
overrides: {
meta: { scope: { disabled: true } },
},
diff --git a/x-pack/platform/plugins/shared/actions/public/plugin.ts b/x-pack/platform/plugins/shared/actions/public/plugin.ts
index 5c6b9394e5301..b8cf2f6b38c4e 100644
--- a/x-pack/platform/plugins/shared/actions/public/plugin.ts
+++ b/x-pack/platform/plugins/shared/actions/public/plugin.ts
@@ -17,6 +17,7 @@ export interface ActionsPublicPluginSetup {
enabledEmailServices: string[];
isWebhookSslWithPfxEnabled?: boolean;
isEarsEnabled: boolean;
+ isEarsExperimentalEnabled: boolean;
}
export interface Config {
@@ -36,6 +37,7 @@ export interface Config {
auth?: {
ears?: {
enabled: boolean;
+ enableExperimental: boolean;
};
};
}
@@ -45,6 +47,7 @@ export class Plugin implements CorePlugin {
private readonly enabledEmailServices: string[];
private readonly webhookSslWithPfxEnabled: boolean;
private readonly earsEnabled: boolean;
+ private readonly earsExperimentalEnabled: boolean;
constructor(ctx: PluginInitializerContext) {
const config = ctx.config.get();
@@ -52,6 +55,7 @@ export class Plugin implements CorePlugin {
this.enabledEmailServices = Array.from(new Set(config.email?.services?.enabled || ['*']));
this.webhookSslWithPfxEnabled = config.webhook?.ssl.pfx.enabled ?? true;
this.earsEnabled = config.auth?.ears?.enabled ?? false;
+ this.earsExperimentalEnabled = config.auth?.ears?.enableExperimental ?? false;
}
public setup(): ActionsPublicPluginSetup {
@@ -61,6 +65,7 @@ export class Plugin implements CorePlugin {
enabledEmailServices: this.enabledEmailServices,
isWebhookSslWithPfxEnabled: this.webhookSslWithPfxEnabled,
isEarsEnabled: this.earsEnabled,
+ isEarsExperimentalEnabled: this.earsExperimentalEnabled,
};
}
diff --git a/x-pack/platform/plugins/shared/actions/server/actions_config.mock.ts b/x-pack/platform/plugins/shared/actions/server/actions_config.mock.ts
index 2c0ff2d538081..47940c34186ad 100644
--- a/x-pack/platform/plugins/shared/actions/server/actions_config.mock.ts
+++ b/x-pack/platform/plugins/shared/actions/server/actions_config.mock.ts
@@ -49,6 +49,7 @@ const createActionsConfigMock = () => {
getMaxEmailBodyLength: jest.fn().mockReturnValue(DEFAULT_EMAIL_BODY_LENGTH),
getEarsUrl: jest.fn().mockReturnValue(undefined),
isEarsEnabled: jest.fn().mockReturnValue(false),
+ isEarsExperimentalEnabled: jest.fn().mockReturnValue(false),
};
return mocked;
};
diff --git a/x-pack/platform/plugins/shared/actions/server/actions_config.test.ts b/x-pack/platform/plugins/shared/actions/server/actions_config.test.ts
index d57cc5c85757a..c723f0dcc3a4c 100644
--- a/x-pack/platform/plugins/shared/actions/server/actions_config.test.ts
+++ b/x-pack/platform/plugins/shared/actions/server/actions_config.test.ts
@@ -49,7 +49,7 @@ const defaultActionsConfig: ActionsConfig = {
callback: { lookbackWindow: '1h', limit: 100 },
},
},
- ears: { enabled: false },
+ ears: { enabled: false, enableExperimental: false },
},
};
@@ -788,7 +788,7 @@ describe('getEarsUrl()', () => {
...defaultActionsConfig,
auth: {
...defaultActionsConfig.auth,
- ears: { enabled: false, url: 'https://ears.example.com' },
+ ears: { enabled: false, enableExperimental: false, url: 'https://ears.example.com' },
},
});
expect(acu.getEarsUrl()).toBe('https://ears.example.com');
@@ -806,13 +806,31 @@ describe('isEarsEnabled()', () => {
...defaultActionsConfig,
auth: {
...defaultActionsConfig.auth,
- ears: { enabled: true },
+ ears: { enabled: true, enableExperimental: false },
},
});
expect(acu.isEarsEnabled()).toBe(true);
});
});
+describe('isEarsExperimentalEnabled()', () => {
+ test('returns false when neither config key is set', () => {
+ const acu = getActionsConfigurationUtilities(defaultActionsConfig);
+ expect(acu.isEarsExperimentalEnabled()).toBe(false);
+ });
+
+ test('returns true when auth.ears.enableExperimental is true', () => {
+ const acu = getActionsConfigurationUtilities({
+ ...defaultActionsConfig,
+ auth: {
+ ...defaultActionsConfig.auth,
+ ears: { enabled: true, enableExperimental: true },
+ },
+ });
+ expect(acu.isEarsExperimentalEnabled()).toBe(true);
+ });
+});
+
describe('getEnabledEmailServices()', () => {
test('returns all services when no email config set', () => {
const acu = getActionsConfigurationUtilities(defaultActionsConfig);
diff --git a/x-pack/platform/plugins/shared/actions/server/actions_config.ts b/x-pack/platform/plugins/shared/actions/server/actions_config.ts
index 0ee05c0b3a5e2..924c44b03b3ed 100644
--- a/x-pack/platform/plugins/shared/actions/server/actions_config.ts
+++ b/x-pack/platform/plugins/shared/actions/server/actions_config.ts
@@ -77,6 +77,7 @@ export interface ActionsConfigurationUtilities {
getMaxEmailBodyLength: () => number;
getEarsUrl: () => string | undefined;
isEarsEnabled: () => boolean;
+ isEarsExperimentalEnabled: () => boolean;
}
function allowListErrorMessage(field: AllowListingField, value: string) {
@@ -283,5 +284,6 @@ export function getActionsConfigurationUtilities(
},
getEarsUrl: () => config.auth.ears?.url,
isEarsEnabled: () => config.auth.ears?.enabled ?? false,
+ isEarsExperimentalEnabled: () => config.auth.ears?.enableExperimental ?? false,
};
}
diff --git a/x-pack/platform/plugins/shared/actions/server/application/connector/methods/get_connector_spec/get_connector_spec.test.ts b/x-pack/platform/plugins/shared/actions/server/application/connector/methods/get_connector_spec/get_connector_spec.test.ts
index d93ca68b96430..f278d4ba87916 100644
--- a/x-pack/platform/plugins/shared/actions/server/application/connector/methods/get_connector_spec/get_connector_spec.test.ts
+++ b/x-pack/platform/plugins/shared/actions/server/application/connector/methods/get_connector_spec/get_connector_spec.test.ts
@@ -17,6 +17,7 @@ const auditLogger = auditLoggerMock.create();
const configurationUtilities = {
getWebhookSettings: () => ({ ssl: { pfx: { enabled: true } } }),
isEarsEnabled: () => false,
+ isEarsExperimentalEnabled: () => false,
} as unknown as ActionsConfigurationUtilities;
function createContext(): ActionsClientContext {
@@ -101,4 +102,82 @@ describe('getConnectorSpecAsJsonSchema', () => {
})
).rejects.toMatchObject({ output: { statusCode: 404 } });
});
+
+ it('excludes experimental EARS auth types when isEarsExperimentalEnabled is false', async () => {
+ const earsEnabledUtils = {
+ getWebhookSettings: () => ({ ssl: { pfx: { enabled: true } } }),
+ isEarsEnabled: () => true,
+ isEarsExperimentalEnabled: () => false,
+ } as unknown as ActionsConfigurationUtilities;
+
+ const result = await getConnectorSpecAsJsonSchema({
+ context: createContext(),
+ id: '.google_calendar',
+ configurationUtilities: earsEnabledUtils,
+ });
+
+ const schemaJson = result.schema as {
+ properties?: {
+ secrets?: { oneOf?: Array<{ properties?: { authType?: { const?: string } } }> };
+ };
+ };
+ const secretsOneOf = schemaJson.properties?.secrets?.oneOf || [];
+ const authTypes = secretsOneOf
+ .map((opt) => opt.properties?.authType?.const)
+ .filter(Boolean) as string[];
+
+ expect(authTypes).not.toContain('ears');
+ });
+
+ it('includes stable (non-experimental) EARS auth types even when isEarsExperimentalEnabled is false', async () => {
+ const earsEnabledUtils = {
+ getWebhookSettings: () => ({ ssl: { pfx: { enabled: true } } }),
+ isEarsEnabled: () => true,
+ isEarsExperimentalEnabled: () => false,
+ } as unknown as ActionsConfigurationUtilities;
+
+ const result = await getConnectorSpecAsJsonSchema({
+ context: createContext(),
+ id: '.microsoft-teams',
+ configurationUtilities: earsEnabledUtils,
+ });
+
+ const schemaJson = result.schema as {
+ properties?: {
+ secrets?: { oneOf?: Array<{ properties?: { authType?: { const?: string } } }> };
+ };
+ };
+ const secretsOneOf = schemaJson.properties?.secrets?.oneOf || [];
+ const authTypes = secretsOneOf
+ .map((opt) => opt.properties?.authType?.const)
+ .filter(Boolean) as string[];
+
+ expect(authTypes).toContain('ears');
+ });
+
+ it('includes experimental EARS auth types when isEarsExperimentalEnabled is true', async () => {
+ const earsEnabledUtils = {
+ getWebhookSettings: () => ({ ssl: { pfx: { enabled: true } } }),
+ isEarsEnabled: () => true,
+ isEarsExperimentalEnabled: () => true,
+ } as unknown as ActionsConfigurationUtilities;
+
+ const result = await getConnectorSpecAsJsonSchema({
+ context: createContext(),
+ id: '.google_calendar',
+ configurationUtilities: earsEnabledUtils,
+ });
+
+ const schemaJson = result.schema as {
+ properties?: {
+ secrets?: { oneOf?: Array<{ properties?: { authType?: { const?: string } } }> };
+ };
+ };
+ const secretsOneOf = schemaJson.properties?.secrets?.oneOf || [];
+ const authTypes = secretsOneOf
+ .map((opt) => opt.properties?.authType?.const)
+ .filter(Boolean) as string[];
+
+ expect(authTypes).toContain('ears');
+ });
});
diff --git a/x-pack/platform/plugins/shared/actions/server/application/connector/methods/get_connector_spec/get_connector_spec.ts b/x-pack/platform/plugins/shared/actions/server/application/connector/methods/get_connector_spec/get_connector_spec.ts
index 4923df379e2e2..cd531c21c70bb 100644
--- a/x-pack/platform/plugins/shared/actions/server/application/connector/methods/get_connector_spec/get_connector_spec.ts
+++ b/x-pack/platform/plugins/shared/actions/server/application/connector/methods/get_connector_spec/get_connector_spec.ts
@@ -42,7 +42,12 @@ export async function getConnectorSpecAsJsonSchema({
const webhookSettings = configurationUtilities.getWebhookSettings();
const isPfxEnabled = webhookSettings.ssl.pfx.enabled;
const isEarsEnabled = configurationUtilities.isEarsEnabled();
- const serialized = serializeConnectorSpec(spec, { isPfxEnabled, isEarsEnabled });
+ const isEarsExperimentalEnabled = configurationUtilities.isEarsExperimentalEnabled();
+ const serialized = serializeConnectorSpec(spec, {
+ isPfxEnabled,
+ isEarsEnabled,
+ isEarsExperimentalEnabled,
+ });
return {
metadata: serialized.metadata,
schema: serialized.schema,
diff --git a/x-pack/platform/plugins/shared/actions/server/config.ts b/x-pack/platform/plugins/shared/actions/server/config.ts
index 9214baa892544..d19e46109ff39 100644
--- a/x-pack/platform/plugins/shared/actions/server/config.ts
+++ b/x-pack/platform/plugins/shared/actions/server/config.ts
@@ -219,6 +219,7 @@ export const configSchema = schema.object({
ears: schema.maybe(
schema.object({
enabled: schema.boolean({ defaultValue: false }),
+ enableExperimental: schema.boolean({ defaultValue: false }),
url: schema.maybe(schema.uri({ scheme: ['https'] })),
})
),
diff --git a/x-pack/platform/plugins/shared/actions/server/index.ts b/x-pack/platform/plugins/shared/actions/server/index.ts
index 7941b87ac0a42..c75a691842d68 100644
--- a/x-pack/platform/plugins/shared/actions/server/index.ts
+++ b/x-pack/platform/plugins/shared/actions/server/index.ts
@@ -60,7 +60,7 @@ export const config: PluginConfigDescriptor = {
// recipient_allowlist is not exposed because it may contain sensitive information
email: { domain_allowlist: true, recipient_allowlist: false, services: { enabled: true } },
webhook: { ssl: { pfx: { enabled: true } } },
- auth: { ears: { enabled: true } },
+ auth: { ears: { enabled: true, enableExperimental: true } },
},
};
diff --git a/x-pack/platform/plugins/shared/actions/server/lib/single_file_connectors/generate_secrets_schema.test.ts b/x-pack/platform/plugins/shared/actions/server/lib/single_file_connectors/generate_secrets_schema.test.ts
index 8f12d79a10918..58342cd6d29d5 100644
--- a/x-pack/platform/plugins/shared/actions/server/lib/single_file_connectors/generate_secrets_schema.test.ts
+++ b/x-pack/platform/plugins/shared/actions/server/lib/single_file_connectors/generate_secrets_schema.test.ts
@@ -139,6 +139,61 @@ describe('generateSecretsSchema', () => {
});
});
+ describe('customValidator - experimental EARS auth gating', () => {
+ const experimentalEarsAuthSpec: ConnectorSpec['auth'] = {
+ types: [
+ 'none',
+ {
+ type: 'ears',
+ isExperimental: true,
+ defaults: { provider: 'google', scope: 'https://www.googleapis.com/auth/calendar' },
+ },
+ ],
+ };
+
+ const stableEarsAuthSpec: ConnectorSpec['auth'] = {
+ types: [
+ 'none',
+ {
+ type: 'ears',
+ defaults: { provider: 'slack', scope: 'channels:read' },
+ },
+ ],
+ };
+
+ it('throws when EARS is enabled but experimental is disabled for an experimental provider', () => {
+ mockConfigUtils.isEarsEnabled.mockReturnValue(true);
+ mockConfigUtils.isEarsExperimentalEnabled.mockReturnValue(false);
+ const validator = generateSecretsSchema(experimentalEarsAuthSpec, mockConfigUtils);
+
+ expect(() =>
+ validator.customValidator!({ authType: 'ears', provider: 'google' }, validatorServices)
+ ).toThrow(
+ 'EARS OAuth authentication is not enabled for the "google" provider. Enable it via xpack.actions.auth.ears.enableExperimental in kibana.yml.'
+ );
+ });
+
+ it('does not throw when both EARS and experimental are enabled for an experimental provider', () => {
+ mockConfigUtils.isEarsEnabled.mockReturnValue(true);
+ mockConfigUtils.isEarsExperimentalEnabled.mockReturnValue(true);
+ const validator = generateSecretsSchema(experimentalEarsAuthSpec, mockConfigUtils);
+
+ expect(() =>
+ validator.customValidator!({ authType: 'ears', provider: 'google' }, validatorServices)
+ ).not.toThrow();
+ });
+
+ it('does not throw for stable EARS providers when experimental is disabled', () => {
+ mockConfigUtils.isEarsEnabled.mockReturnValue(true);
+ mockConfigUtils.isEarsExperimentalEnabled.mockReturnValue(false);
+ const validator = generateSecretsSchema(stableEarsAuthSpec, mockConfigUtils);
+
+ expect(() =>
+ validator.customValidator!({ authType: 'ears', provider: 'slack' }, validatorServices)
+ ).not.toThrow();
+ });
+ });
+
describe('customValidator - allowedHosts validation for URL fields', () => {
const oauthAuthSpec = {
types: [
diff --git a/x-pack/platform/plugins/shared/actions/server/lib/single_file_connectors/generate_secrets_schema.ts b/x-pack/platform/plugins/shared/actions/server/lib/single_file_connectors/generate_secrets_schema.ts
index eab6c2a214d1f..dd1024e08788e 100644
--- a/x-pack/platform/plugins/shared/actions/server/lib/single_file_connectors/generate_secrets_schema.ts
+++ b/x-pack/platform/plugins/shared/actions/server/lib/single_file_connectors/generate_secrets_schema.ts
@@ -7,6 +7,7 @@
import type { ConnectorSpec } from '@kbn/connector-specs';
import { generateSecretsSchemaFromSpec } from '@kbn/connector-specs/src/lib/generate_secrets_schema_from_spec';
+import { isEarsExperimentalAuthType } from '@kbn/connector-specs/src/lib/ears_experimental_utils';
import { getSchemaForAuthType } from '@kbn/connector-specs/src/lib/get_schema_for_auth_type';
import type { ActionTypeSecrets, ValidatorType, ValidatorServices } from '../../types';
import type { ActionsConfigurationUtilities } from '../../actions_config';
@@ -32,10 +33,20 @@ export const generateSecretsSchema = (
): ValidatorType => {
const settings = configUtils.getWebhookSettings();
const isPfxEnabled = settings.ssl.pfx.enabled;
- // Always include EARS in the static schema regardless of the feature flag.
+ // Always include EARS in the static schema regardless of feature flags.
// This lets the customValidator (below) return a readable error message when EARS is
// disabled, instead of a cryptic Zod union discriminator error.
- const schema = generateSecretsSchemaFromSpec(authSpec, { isPfxEnabled, isEarsEnabled: true });
+ const schema = generateSecretsSchemaFromSpec(authSpec, {
+ isPfxEnabled,
+ isEarsEnabled: true,
+ isEarsExperimentalEnabled: true,
+ });
+
+ const experimentalEarsAuthType = authSpec?.types.find(isEarsExperimentalAuthType);
+ const hasExperimentalEarsAuthType = experimentalEarsAuthType !== undefined;
+ const experimentalEarsProvider = hasExperimentalEarsAuthType
+ ? String(experimentalEarsAuthType.defaults?.provider ?? 'unknown')
+ : 'unknown';
const allowedHostsFieldsByAuthType = buildAllowedHostsFieldsByAuthType(authSpec);
@@ -55,6 +66,16 @@ export const generateSecretsSchema = (
);
}
+ if (
+ authType === 'ears' &&
+ hasExperimentalEarsAuthType &&
+ !configurationUtilities.isEarsExperimentalEnabled()
+ ) {
+ throw new Error(
+ `EARS OAuth authentication is not enabled for the "${experimentalEarsProvider}" provider. Enable it via xpack.actions.auth.ears.enableExperimental in kibana.yml.`
+ );
+ }
+
const allowedHostsFields = allowedHostsFieldsByAuthType.get(authType);
if (!allowedHostsFields) return;
diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/connectors/table/connectors_table.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/connectors/table/connectors_table.tsx
index 245469bab373d..b4da9e4762ccb 100644
--- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/connectors/table/connectors_table.tsx
+++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/connectors/table/connectors_table.tsx
@@ -9,6 +9,7 @@ import type { CriteriaWithPagination } from '@elastic/eui';
import { EuiInMemoryTable, EuiSkeletonText, EuiText, useEuiTheme } from '@elastic/eui';
import React, { memo, useEffect, useState } from 'react';
import { css } from '@emotion/react';
+import { isEarsExperimentalConnector } from '@kbn/connector-specs';
import type { ConnectorItem } from '../../../../../common/http_api/tools';
import { useListConnectors } from '../../../hooks/tools/use_mcp_connectors';
import { useAgentBuilderServices } from '../../../hooks/use_agent_builder_service';
@@ -31,10 +32,15 @@ export const AgentBuilderConnectorsTable = memo(() => {
}, [tableConnectors]);
const { euiTheme } = useEuiTheme();
- const { isEarsEnabled } = useAgentBuilderServices();
+ const { isEarsEnabled, isEarsExperimentalEnabled } = useAgentBuilderServices();
- const isDisabledEarsConnector = (connector: ConnectorItem): boolean =>
- !isEarsEnabled && connector.config?.authType === 'ears';
+ const isDisabledEarsConnector = (connector: ConnectorItem): boolean => {
+ if (connector.config?.authType !== 'ears') return false;
+ if (!isEarsEnabled) return true;
+ if (isEarsExperimentalConnector(connector.actionTypeId) && !isEarsExperimentalEnabled)
+ return true;
+ return false;
+ };
const disabledRowCss = css({ backgroundColor: euiTheme.colors.lightestShade });
diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/connectors/table/connectors_table_columns.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/connectors/table/connectors_table_columns.tsx
index d3c542978c05f..93948b4f0cc7a 100644
--- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/connectors/table/connectors_table_columns.tsx
+++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/connectors/table/connectors_table_columns.tsx
@@ -20,6 +20,7 @@ import { AGENT_BUILDER_UI_EBT } from '@kbn/agent-builder-common';
import { getEbtProps } from '@kbn/ebt-click';
import { useConnectorOAuthConnect, OAuthRedirectMode } from '@kbn/response-ops-oauth-hooks';
import React, { useMemo } from 'react';
+import { isEarsExperimentalConnector } from '@kbn/connector-specs';
import type { ConnectorItem } from '../../../../../common/http_api/tools';
import { OAUTH_STATUS } from '../../../../../common/http_api/tools';
import { useConnectorsActions } from '../../../context/connectors_provider';
@@ -92,11 +93,13 @@ export const useConnectorsTableColumns = (): Array {
const isDisabledEarsConnector = (connector: ConnectorItem): boolean =>
- !isEarsEnabled && connector.config?.authType === 'ears';
+ connector.config?.authType === 'ears' &&
+ (!isEarsEnabled ||
+ (isEarsExperimentalConnector(connector.actionTypeId) && !isEarsExperimentalEnabled));
return [
{
@@ -199,5 +202,5 @@ export const useConnectorsTableColumns = (): Array setIsOpen(false);
@@ -117,11 +122,16 @@ export const ConnectorContextMenu = ({ connector }: ConnectorContextMenuProps) =
aria-label={labels.connectors.connectorContextMenuButtonLabel}
panelPaddingSize="s"
button={
- setIsOpen((openState) => !openState)}
- aria-label={labels.connectors.connectorContextMenuButtonLabel}
- />
+
+ setIsOpen((openState) => !openState)}
+ aria-label={labels.connectors.connectorContextMenuButtonLabel}
+ />
+
}
isOpen={isOpen}
closePopover={closeMenu}
diff --git a/x-pack/platform/plugins/shared/agent_builder/public/plugin.tsx b/x-pack/platform/plugins/shared/agent_builder/public/plugin.tsx
index 95da1b744bf90..55d2f621f8839 100644
--- a/x-pack/platform/plugins/shared/agent_builder/public/plugin.tsx
+++ b/x-pack/platform/plugins/shared/agent_builder/public/plugin.tsx
@@ -93,6 +93,7 @@ export class AgentBuilderPlugin
} | null = null;
private appUpdater$ = new BehaviorSubject(() => ({}));
private isEarsEnabled = false;
+ private isEarsExperimentalEnabled = false;
private experimentalDeepLinksSubscription?: Subscription;
constructor(context: PluginInitializerContext) {
@@ -109,6 +110,7 @@ export class AgentBuilderPlugin
this.setupServices = { navigationService, usageCollection: deps.usageCollection };
this.isEarsEnabled = deps.actions.isEarsEnabled;
+ this.isEarsExperimentalEnabled = deps.actions.isEarsExperimentalEnabled;
registerApp({
core,
@@ -234,6 +236,7 @@ export class AgentBuilderPlugin
accessChecker,
eventsService,
isEarsEnabled: this.isEarsEnabled,
+ isEarsExperimentalEnabled: this.isEarsExperimentalEnabled,
openSidebarConversation: (options?: OpenSidebarInternalOptions) => {
return openSidebarInternal(options);
},
diff --git a/x-pack/platform/plugins/shared/agent_builder/public/services/types.ts b/x-pack/platform/plugins/shared/agent_builder/public/services/types.ts
index 78e2b6496a7f5..6c277103fd130 100644
--- a/x-pack/platform/plugins/shared/agent_builder/public/services/types.ts
+++ b/x-pack/platform/plugins/shared/agent_builder/public/services/types.ts
@@ -39,5 +39,6 @@ export interface AgentBuilderInternalService {
accessChecker: AgentBuilderAccessChecker;
eventsService: EventsService;
isEarsEnabled: boolean;
+ isEarsExperimentalEnabled: boolean;
openSidebarConversation: (options?: OpenSidebarInternalOptions) => OpenConversationSidebarReturn;
}
diff --git a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types_from_spec/generate_schema.ts b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types_from_spec/generate_schema.ts
index dc203d4c32276..c9443b01e576c 100644
--- a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types_from_spec/generate_schema.ts
+++ b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types_from_spec/generate_schema.ts
@@ -14,11 +14,22 @@ export const generateSchema = (
{
isPfxEnabled,
isEarsEnabled,
+ isEarsExperimentalEnabled,
authMode,
- }: { isPfxEnabled?: boolean; isEarsEnabled?: boolean; authMode?: AuthMode } = {}
+ }: {
+ isPfxEnabled?: boolean;
+ isEarsEnabled?: boolean;
+ isEarsExperimentalEnabled?: boolean;
+ authMode?: AuthMode;
+ } = {}
) => {
return z.object({
config: spec.schema ?? z.object({}),
- secrets: generateSecretsSchemaFromSpec(spec.auth, { isPfxEnabled, isEarsEnabled, authMode }),
+ secrets: generateSecretsSchemaFromSpec(spec.auth, {
+ isPfxEnabled,
+ isEarsEnabled,
+ isEarsExperimentalEnabled,
+ authMode,
+ }),
});
};
diff --git a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types_from_spec/register_from_spec.test.ts b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types_from_spec/register_from_spec.test.ts
index ec50e14ab6fc7..56c79b0dcbb92 100644
--- a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types_from_spec/register_from_spec.test.ts
+++ b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types_from_spec/register_from_spec.test.ts
@@ -98,6 +98,7 @@ describe('registerConnectorTypesFromSpecs', () => {
connectorTypeRegistry as unknown as TriggersAndActionsUIPublicPluginSetup['actionTypeRegistry'],
uiSettingsPromise,
isEarsEnabled: false,
+ isEarsExperimentalEnabled: false,
});
// Wait for the async import to complete
@@ -140,6 +141,7 @@ describe('registerConnectorTypesFromSpecs', () => {
connectorTypeRegistry as unknown as TriggersAndActionsUIPublicPluginSetup['actionTypeRegistry'],
uiSettingsPromise,
isEarsEnabled: false,
+ isEarsExperimentalEnabled: false,
});
// Wait for the async import and uiSettings promise to complete
@@ -176,6 +178,7 @@ describe('registerConnectorTypesFromSpecs', () => {
connectorTypeRegistry as unknown as TriggersAndActionsUIPublicPluginSetup['actionTypeRegistry'],
uiSettingsPromise,
isEarsEnabled: false,
+ isEarsExperimentalEnabled: false,
});
// Wait for the async import and uiSettings promise to complete
@@ -211,6 +214,7 @@ describe('registerConnectorTypesFromSpecs', () => {
connectorTypeRegistry as unknown as TriggersAndActionsUIPublicPluginSetup['actionTypeRegistry'],
uiSettingsPromise,
isEarsEnabled: false,
+ isEarsExperimentalEnabled: false,
});
// Wait for the async import to complete
@@ -242,6 +246,7 @@ describe('registerConnectorTypesFromSpecs', () => {
connectorTypeRegistry as unknown as TriggersAndActionsUIPublicPluginSetup['actionTypeRegistry'],
uiSettingsPromise,
isEarsEnabled: false,
+ isEarsExperimentalEnabled: false,
});
// Wait for the async import to complete
@@ -260,6 +265,7 @@ describe('registerConnectorTypesFromSpecs', () => {
connectorTypeRegistry as unknown as TriggersAndActionsUIPublicPluginSetup['actionTypeRegistry'],
uiSettingsPromise,
isEarsEnabled: false,
+ isEarsExperimentalEnabled: false,
});
// Wait for the async import to complete
@@ -277,6 +283,7 @@ describe('registerConnectorTypesFromSpecs', () => {
connectorTypeRegistry as unknown as TriggersAndActionsUIPublicPluginSetup['actionTypeRegistry'],
uiSettingsPromise,
isEarsEnabled: false,
+ isEarsExperimentalEnabled: false,
});
await new Promise((resolve) => setTimeout(resolve, 0));
diff --git a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types_from_spec/register_from_spec.ts b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types_from_spec/register_from_spec.ts
index 3da25db02b1f7..f1c45c237eb43 100644
--- a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types_from_spec/register_from_spec.ts
+++ b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types_from_spec/register_from_spec.ts
@@ -22,10 +22,12 @@ export function registerConnectorTypesFromSpecs({
connectorTypeRegistry,
uiSettingsPromise,
isEarsEnabled,
+ isEarsExperimentalEnabled,
}: {
connectorTypeRegistry: TriggersAndActionsUIPublicPluginSetup['actionTypeRegistry'];
uiSettingsPromise: Promise;
isEarsEnabled: boolean;
+ isEarsExperimentalEnabled: boolean;
}) {
// TODO: Clean this up when workflows:ui:enabled setting is removed.
// This is a workaround to avoid making the whole thing async.
@@ -54,7 +56,14 @@ export function registerConnectorTypesFromSpecs({
]).then(([{ connectorsSpecs }, { generateFormFields }, { generateSchema }]) => {
for (const spec of Object.values(connectorsSpecs)) {
connectorTypeRegistry.register(
- createConnectorTypeFromSpec(spec, ref, generateFormFields, generateSchema, isEarsEnabled)
+ createConnectorTypeFromSpec(
+ spec,
+ ref,
+ generateFormFields,
+ generateSchema,
+ isEarsEnabled,
+ isEarsExperimentalEnabled
+ )
);
}
});
@@ -64,11 +73,17 @@ const createConnectorFields = (
spec: ConnectorSpec,
generateFormFields: typeof import('@kbn/response-ops-form-generator').generateFormFields,
generateSchema: typeof import('./generate_schema').generateSchema,
- isEarsEnabled: boolean
+ isEarsEnabled: boolean,
+ isEarsExperimentalEnabled: boolean
) => {
const ConnectorFields = (props: ActionConnectorFieldsProps) => {
const schema = useMemo(
- () => generateSchema(spec, { isEarsEnabled, authMode: props.authMode }),
+ () =>
+ generateSchema(spec, {
+ isEarsEnabled,
+ isEarsExperimentalEnabled,
+ authMode: props.authMode,
+ }),
[props.authMode]
);
@@ -86,9 +101,10 @@ const createConnectorTypeFromSpec = (
ref: { uiSettings?: IUiSettingsClient },
generateFormFields: typeof import('@kbn/response-ops-form-generator').generateFormFields,
generateSchema: typeof import('./generate_schema').generateSchema,
- isEarsEnabled: boolean
+ isEarsEnabled: boolean,
+ isEarsExperimentalEnabled: boolean
): ActionTypeModel => {
- const schema = generateSchema(spec, { isEarsEnabled });
+ const schema = generateSchema(spec, { isEarsEnabled, isEarsExperimentalEnabled });
return {
id: spec.metadata.id,
@@ -109,7 +125,13 @@ const createConnectorTypeFromSpec = (
},
actionConnectorFields: lazy(() =>
Promise.resolve({
- default: createConnectorFields(spec, generateFormFields, generateSchema, isEarsEnabled),
+ default: createConnectorFields(
+ spec,
+ generateFormFields,
+ generateSchema,
+ isEarsEnabled,
+ isEarsExperimentalEnabled
+ ),
})
),
actionParamsFields: lazy(() => Promise.resolve({ default: () => null })),
diff --git a/x-pack/platform/plugins/shared/stack_connectors/public/plugin.ts b/x-pack/platform/plugins/shared/stack_connectors/public/plugin.ts
index 077914456ae69..64774dfdf2bf3 100644
--- a/x-pack/platform/plugins/shared/stack_connectors/public/plugin.ts
+++ b/x-pack/platform/plugins/shared/stack_connectors/public/plugin.ts
@@ -50,6 +50,7 @@ export class StackConnectorsPublicPlugin
connectorTypeRegistry: triggersActionsUi.actionTypeRegistry,
uiSettingsPromise: core.getStartServices().then(([coreStart]) => coreStart.uiSettings),
isEarsEnabled: actions.isEarsEnabled,
+ isEarsExperimentalEnabled: actions.isEarsExperimentalEnabled,
});
}
}
diff --git a/x-pack/platform/plugins/shared/triggers_actions_ui/moon.yml b/x-pack/platform/plugins/shared/triggers_actions_ui/moon.yml
index 954f08f9cfe84..7a3d764b91cbc 100644
--- a/x-pack/platform/plugins/shared/triggers_actions_ui/moon.yml
+++ b/x-pack/platform/plugins/shared/triggers_actions_ui/moon.yml
@@ -106,6 +106,7 @@ dependsOn:
- '@kbn/std'
- '@kbn/lens-common'
- '@kbn/inspector-plugin'
+ - '@kbn/connector-specs'
tags:
- plugin
- prod
diff --git a/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx b/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx
index ceb64881b5379..77eb59affa19b 100644
--- a/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx
+++ b/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx
@@ -32,6 +32,7 @@ import { getConnectorCompatibility } from '@kbn/actions-plugin/common';
import { FormattedMessage } from '@kbn/i18n-react';
import { checkActionTypeEnabled } from '@kbn/alerts-ui-shared/src/check_action_type_enabled';
import { ACTION_TYPE_SOURCES } from '@kbn/actions-types';
+import { isEarsExperimentalConnector } from '@kbn/connector-specs';
import {
DEPRECATED_CONNECTOR_TOOLTIP_CONTENT,
DEPRECATED_LABEL,
@@ -105,7 +106,7 @@ const ActionsConnectorsList = ({
setBreadcrumbs,
chrome,
docLinks,
- actions: { isEarsEnabled },
+ actions: { isEarsEnabled, isEarsExperimentalEnabled },
} = useKibana().services;
const { euiTheme } = useEuiTheme();
@@ -115,11 +116,15 @@ const ActionsConnectorsList = ({
const canDelete = hasDeleteActionsCapability(capabilities);
const canSave = hasSaveActionsCapability(capabilities);
const isDisabledEarsConnector = useCallback(
- (item: ActionConnectorTableItem | ActionConnector) =>
- !isEarsEnabled &&
- 'config' in item &&
- (item.config as Record)?.authType === 'ears',
- [isEarsEnabled]
+ (item: ActionConnectorTableItem | ActionConnector) => {
+ if (!('config' in item) || (item.config as Record)?.authType !== 'ears') {
+ return false;
+ }
+ if (!isEarsEnabled) return true;
+ if (isEarsExperimentalConnector(item.actionTypeId) && !isEarsExperimentalEnabled) return true;
+ return false;
+ },
+ [isEarsEnabled, isEarsExperimentalEnabled]
);
const [actionTypesIndex, setActionTypesIndex] = useState(undefined);
diff --git a/x-pack/platform/plugins/shared/triggers_actions_ui/public/common/lib/kibana/kibana_react.mock.ts b/x-pack/platform/plugins/shared/triggers_actions_ui/public/common/lib/kibana/kibana_react.mock.ts
index 73b92c764eb39..72907ed46c83a 100644
--- a/x-pack/platform/plugins/shared/triggers_actions_ui/public/common/lib/kibana/kibana_react.mock.ts
+++ b/x-pack/platform/plugins/shared/triggers_actions_ui/public/common/lib/kibana/kibana_react.mock.ts
@@ -32,6 +32,7 @@ export const createStartServicesMock = (): TriggersAndActionsUiServices => {
validateEmailAddresses: jest.fn(),
enabledEmailServices: ['*'],
isEarsEnabled: false,
+ isEarsExperimentalEnabled: false,
},
ruleTypeRegistry: {
has: jest.fn(),
diff --git a/x-pack/platform/plugins/shared/triggers_actions_ui/tsconfig.json b/x-pack/platform/plugins/shared/triggers_actions_ui/tsconfig.json
index b190435964e05..ab0ea90843276 100644
--- a/x-pack/platform/plugins/shared/triggers_actions_ui/tsconfig.json
+++ b/x-pack/platform/plugins/shared/triggers_actions_ui/tsconfig.json
@@ -100,7 +100,8 @@
"@kbn/security-plugin",
"@kbn/std",
"@kbn/lens-common",
- "@kbn/inspector-plugin"
+ "@kbn/inspector-plugin",
+ "@kbn/connector-specs"
],
"exclude": ["target/**/*"]
}
From cbaf473bc8df7928ee2f97518000533492083cb0 Mon Sep 17 00:00:00 2001
From: Ying Mao
Date: Wed, 27 May 2026 10:36:35 -0400
Subject: [PATCH 039/193] [Entity Analytics] Anomaly detection behavior
maintainer (#269309)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
> [!NOTE]
> This only contains the server side changes for the entity behavior
feature. UI changes to come in subsequent PRs.
Introduces a new entity maintainer (`ml-anomaly-detection-jobs`) that
maintains the `entity.behaviors.anomaly_job_ids` field for an entity in
the entity store. This maintainer runs every 24 hours and looks back 90
days in order to capture all of the anomalous behavior for an entity in
the last 90 days.
During each run, the maintainer:
- Iterates over user and host entities from the entity store in batches
- For each batch, fetches anomaly records from security ML jobs for the
last 90 days and above the configured threshold minimum
- If anomaly records exist for an entity, its entity store entry is
updated to include the anomaly job ID.
- If anomaly records exist for an entity, additional supporting details
will be queried and stored in a details datastream
`.entity_analytics.ml-ad-jobs-latest-${namespace}`
The additional details that are fetched are job dependent:
For jobs that use the `rare` function (for example, rare country login),
only the anomalous value is stored in the anomaly record (for example,
`Iran`). In order to determine the baseline behavior for the entity, we
use the ML job configuration to aggregate against the source index (for
example, an aggregation to determine where an entity commonly logs in)
For other job types that are metric or count functions (for example,
high number of failed logins), the record document contains the typical
value and the anomalous value so we already have the baseline behavior.
For all job types, we grab the latest 3 anomalous documents. This is to
support the "Raw Evidence" portion of the expanded section in the
initial UI mockups. Note that the exact format of these documents may
change as we finalize the mockups but since this feature is behind a
feature flag, it should be ok to merge and finalize later.
## To Verify
1. Modify the default lookback period of the entity store logs
extraction task (because we're populating historical data)
```
--- a/x-pack/solutions/security/plugins/entity_store/server/domain/saved_objects/global_state/constants.ts
+++ b/x-pack/solutions/security/plugins/entity_store/server/domain/saved_objects/global_state/constants.ts
@@ -10,14 +10,14 @@ import { z } from '@kbn/zod/v4';
export const DEFAULT_HISTORY_SNAPSHOT_FREQUENCY = '24h';
export const LOG_EXTRACTION_DELAY_DEFAULT = '1m';
-export const LOG_EXTRACTION_LOOKBACK_PERIOD_DEFAULT = '3h';
+export const LOG_EXTRACTION_LOOKBACK_PERIOD_DEFAULT = '30d';
export const LOG_EXTRACTION_FREQUENCY_DEFAULT = '1m';
// Max amount of entities to extract in one ESQL query
export const LOG_EXTRACTION_DOCS_LIMIT_DEFAULT = 10000;
// Max raw log documents per logs to be processed in a query (inside elastic search)
export const LOG_EXTRACTION_MAX_LOGS_PER_PAGE_DEFAULT = 40000;
export const LOG_EXTRACTION_TIMEOUT_DEFAULT = '59s';
-export const LOG_EXTRACTION_MAX_TIME_WINDOW_SIZE_DEFAULT = '15m';
+export const LOG_EXTRACTION_MAX_TIME_WINDOW_SIZE_DEFAULT = '1d';
// Max total raw log documents to process per task run; 0 = no cap
```
2. Start ES and Kibana with the following feature flags:
```
uiSettings.overrides:
securitySolution:entityStoreEnableV2: true
xpack.securitySolution.enableExperimental:
- entityAnalyticsEntityStoreV2
- entityAnalyticsWatchlistEnabled
- entityAnalyticsNewHomePageEnabled
- leadGenerationEnabled
- entityAnalyticsMlJobBehaviorMaintainer ---->> !!! NEW FEATURE FLAG FOR THIS PR !!!
```
3. Use this script to populate some data:
https://gist.github.com/ymao1/d35d356f090e23c746055446cc21fba0. NOTE!!:
You may need to modify the Kibana URL if you're using a different base
path or SSL
You will need to also download these scripts that are referenced by the
above script.
- Rare region data:
https://gist.github.com/ymao1/3f8d1214928b5c27aa505a20b7f2425d
- High login count:
https://gist.github.com/ymao1/fbbdbcf7552455fd155ee52ffcddf67a
4. Verify the maintainer is started in Dev Tools
```
GET kbn:/internal/security/entity_store/entity_maintainers?apiVersion=2
```
Response should include the new `ml-anomaly-detection-jobs` maintainer
and the status should be `started`
```
{
"maintainers": [
{
"id": "ml-anomaly-detection-jobs",
"taskStatus": "started",
"interval": "1d",
"description": "Entity Analytics ML Anomaly Detection Maintainer",
"nextRunAt": "2026-05-19T12:30:27.957Z",
"minLicense": "platinum",
"customState": {},
"runs": 1,
"lastSuccessTimestamp": "2026-05-18T12:30:30.117Z",
"lastErrorTimestamp": null
},
]
}
```
5. Manually run the maintainer
```
POST kbn:/internal/security/entity_store/entity_maintainers/run/ml-anomaly-detection-jobs?apiVersion=2
```
You should see this info log when the maintainer is done:
```
[2026-05-19T17:45:00.929-04:00][INFO ][plugins.securitySolution.ml-anomaly-detection-jobs-default] Maintainer run completed in 2570ms
```
6. After the maintainer runs, you should see some entities populated
with behavior data
```
GET .entities.v2.latest.security_default-00001/_search
{
"query": {
"bool": {
"filter": [
{
"exists": {
"field": "entity.behaviors.anomaly_job_ids"
}
}
]
}
}
}
```
and you should see entries in the details index
```
GET .entity_analytics.ml-ad-jobs-latest-default/_search
```
---------
Co-authored-by: Elastic Machine
---
.../ftr_security_serverless_configs.yml | 1 +
.../ftr_security_stateful_configs.yml | 1 +
.../common/experimental_features.ts | 5 +
.../fetch_baseline_behavior.test.ts.snap | 166 +++
.../ml_anomaly_detection/constants.ts | 35 +
.../details_index.test.ts | 86 ++
.../ml_anomaly_detection/details_index.ts | 91 ++
.../enrich_and_persist.test.ts | 435 ++++++++
.../enrich_and_persist.ts | 109 ++
.../fetch_anomalies.test.ts | 576 +++++++++++
.../ml_anomaly_detection/fetch_anomalies.ts | 181 ++++
.../fetch_baseline_behavior.test.ts | 861 ++++++++++++++++
.../fetch_baseline_behavior.ts | 488 +++++++++
.../get_security_ml_job_ids.ts | 37 +
.../behaviors/ml_anomaly_detection/index.ts | 8 +
.../ml_anomaly_detection/maintainer.ts | 222 ++++
.../ml_anomaly_detection/register.ts | 52 +
.../ml_anomaly_detection/test_helpers.ts | 123 +++
.../behaviors/ml_anomaly_detection/types.ts | 56 ++
.../update_entity_store.test.ts | 133 +++
.../update_entity_store.ts | 48 +
.../security_solution/server/plugin.ts | 9 +
.../configs/ess.config.ts | 40 +
.../configs/serverless.config.ts | 32 +
.../trial_license_complete_tier/index.ts | 14 +
.../ml_ad_behavior_maintainer.ts | 421 ++++++++
.../trial_license_complete_tier/test_data.ts | 952 ++++++++++++++++++
.../entity_analytics/utils/entity_store.ts | 30 +-
28 files changed, 5206 insertions(+), 6 deletions(-)
create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/maintainers/behaviors/ml_anomaly_detection/__snapshots__/fetch_baseline_behavior.test.ts.snap
create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/maintainers/behaviors/ml_anomaly_detection/constants.ts
create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/maintainers/behaviors/ml_anomaly_detection/details_index.test.ts
create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/maintainers/behaviors/ml_anomaly_detection/details_index.ts
create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/maintainers/behaviors/ml_anomaly_detection/enrich_and_persist.test.ts
create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/maintainers/behaviors/ml_anomaly_detection/enrich_and_persist.ts
create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/maintainers/behaviors/ml_anomaly_detection/fetch_anomalies.test.ts
create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/maintainers/behaviors/ml_anomaly_detection/fetch_anomalies.ts
create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/maintainers/behaviors/ml_anomaly_detection/fetch_baseline_behavior.test.ts
create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/maintainers/behaviors/ml_anomaly_detection/fetch_baseline_behavior.ts
create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/maintainers/behaviors/ml_anomaly_detection/get_security_ml_job_ids.ts
create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/maintainers/behaviors/ml_anomaly_detection/index.ts
create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/maintainers/behaviors/ml_anomaly_detection/maintainer.ts
create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/maintainers/behaviors/ml_anomaly_detection/register.ts
create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/maintainers/behaviors/ml_anomaly_detection/test_helpers.ts
create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/maintainers/behaviors/ml_anomaly_detection/types.ts
create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/maintainers/behaviors/ml_anomaly_detection/update_entity_store.test.ts
create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/maintainers/behaviors/ml_anomaly_detection/update_entity_store.ts
create mode 100644 x-pack/solutions/security/test/security_solution_api_integration/test_suites/entity_analytics/ml_anomaly_detection_maintainer/trial_license_complete_tier/configs/ess.config.ts
create mode 100644 x-pack/solutions/security/test/security_solution_api_integration/test_suites/entity_analytics/ml_anomaly_detection_maintainer/trial_license_complete_tier/configs/serverless.config.ts
create mode 100644 x-pack/solutions/security/test/security_solution_api_integration/test_suites/entity_analytics/ml_anomaly_detection_maintainer/trial_license_complete_tier/index.ts
create mode 100644 x-pack/solutions/security/test/security_solution_api_integration/test_suites/entity_analytics/ml_anomaly_detection_maintainer/trial_license_complete_tier/ml_ad_behavior_maintainer.ts
create mode 100644 x-pack/solutions/security/test/security_solution_api_integration/test_suites/entity_analytics/ml_anomaly_detection_maintainer/trial_license_complete_tier/test_data.ts
diff --git a/.buildkite/ftr-manifests/ftr_security_serverless_configs.yml b/.buildkite/ftr-manifests/ftr_security_serverless_configs.yml
index fc972453dc783..ec8bc2700057b 100644
--- a/.buildkite/ftr-manifests/ftr_security_serverless_configs.yml
+++ b/.buildkite/ftr-manifests/ftr_security_serverless_configs.yml
@@ -129,6 +129,7 @@ enabled:
- x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/conversations/trial_license_complete_tier/configs/serverless.config.ts
- x-pack/solutions/security/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/configs/serverless.config.ts
- x-pack/solutions/security/test/security_solution_api_integration/test_suites/entity_analytics/risk_score_maintainer/trial_license_complete_tier/configs/serverless.config.ts
+ - x-pack/solutions/security/test/security_solution_api_integration/test_suites/entity_analytics/ml_anomaly_detection_maintainer/trial_license_complete_tier/configs/serverless.config.ts
- x-pack/solutions/security/test/security_solution_api_integration/test_suites/entity_analytics/entity_resolution/trial_license_complete_tier/configs/serverless.config.ts
- x-pack/solutions/security/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/basic_license_essentials_tier/configs/serverless.config.ts
- x-pack/solutions/security/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/trial_license_complete_tier/configs/serverless.config.ts
diff --git a/.buildkite/ftr-manifests/ftr_security_stateful_configs.yml b/.buildkite/ftr-manifests/ftr_security_stateful_configs.yml
index 6d2962eff4092..3baaad3bbb49b 100644
--- a/.buildkite/ftr-manifests/ftr_security_stateful_configs.yml
+++ b/.buildkite/ftr-manifests/ftr_security_stateful_configs.yml
@@ -100,6 +100,7 @@ enabled:
- x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/conversations/trial_license_complete_tier/configs/ess.config.ts
- x-pack/solutions/security/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/configs/ess.config.ts
- x-pack/solutions/security/test/security_solution_api_integration/test_suites/entity_analytics/risk_score_maintainer/trial_license_complete_tier/configs/ess.config.ts
+ - x-pack/solutions/security/test/security_solution_api_integration/test_suites/entity_analytics/ml_anomaly_detection_maintainer/trial_license_complete_tier/configs/ess.config.ts
- x-pack/solutions/security/test/security_solution_api_integration/test_suites/entity_analytics/entity_resolution/trial_license_complete_tier/configs/ess.config.ts
- x-pack/solutions/security/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/basic_license_essentials_tier/configs/ess.config.ts
- x-pack/solutions/security/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/trial_license_complete_tier/configs/ess.config.ts
diff --git a/x-pack/solutions/security/plugins/security_solution/common/experimental_features.ts b/x-pack/solutions/security/plugins/security_solution/common/experimental_features.ts
index 1dd559c437e89..c16ee3c983421 100644
--- a/x-pack/solutions/security/plugins/security_solution/common/experimental_features.ts
+++ b/x-pack/solutions/security/plugins/security_solution/common/experimental_features.ts
@@ -246,6 +246,11 @@ export const allowedExperimentalValues = Object.freeze({
*/
entityAnalyticsEntityStoreV2: true,
+ /**
+ * Enables entity ML job behavior maintainer
+ */
+ entityAnalyticsMlJobBehaviorMaintainer: false,
+
/**
* Enables the deprecated prebuilt rules UI
* Release: 9.4
diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/maintainers/behaviors/ml_anomaly_detection/__snapshots__/fetch_baseline_behavior.test.ts.snap b/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/maintainers/behaviors/ml_anomaly_detection/__snapshots__/fetch_baseline_behavior.test.ts.snap
new file mode 100644
index 0000000000000..3d26c0d7b5e3c
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/maintainers/behaviors/ml_anomaly_detection/__snapshots__/fetch_baseline_behavior.test.ts.snap
@@ -0,0 +1,166 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`fetchBaselineBehavior non-rare detector returns top hits from sample_hits aggregation 1`] = `
+Object {
+ "aggs": Object {
+ "sample_hits": Object {
+ "top_hits": Object {
+ "size": 3,
+ },
+ },
+ },
+ "index": Array [
+ "logs-*",
+ ],
+ "query": Object {
+ "bool": Object {
+ "filter": Array [
+ Object {
+ "bool": Object {
+ "filter": Array [
+ Object {
+ "term": Object {
+ "event.action": "authentication",
+ },
+ },
+ ],
+ },
+ },
+ Object {
+ "term": Object {
+ "entity_id": "user:alice",
+ },
+ },
+ Object {
+ "exists": Object {
+ "field": "process.name",
+ },
+ },
+ Object {
+ "exists": Object {
+ "field": "dept",
+ },
+ },
+ Object {
+ "range": Object {
+ "@timestamp": Object {
+ "gte": "now-90d",
+ "lt": 1778241600000,
+ },
+ },
+ },
+ Object {
+ "bool": Object {
+ "must_not": Array [
+ Object {
+ "terms": Object {
+ "_tier": Array [
+ "data_cold",
+ "data_frozen",
+ ],
+ },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ "runtime_mappings": Object {
+ "entity_id": Object {
+ "script": Object {
+ "source": "",
+ },
+ "type": "keyword",
+ },
+ },
+ "size": 0,
+}
+`;
+
+exports[`fetchBaselineBehavior rare detector returns baseline buckets mapped from the aggregation response 1`] = `
+Object {
+ "aggs": Object {
+ "baseline": Object {
+ "aggs": Object {
+ "sample_hits": Object {
+ "top_hits": Object {
+ "size": 3,
+ },
+ },
+ },
+ "terms": Object {
+ "field": "source.ip",
+ "order": Object {
+ "_count": "desc",
+ },
+ "size": 3,
+ },
+ },
+ },
+ "index": Array [
+ "logs-*",
+ ],
+ "query": Object {
+ "bool": Object {
+ "filter": Array [
+ Object {
+ "bool": Object {
+ "filter": Array [
+ Object {
+ "term": Object {
+ "event.action": "authentication",
+ },
+ },
+ ],
+ },
+ },
+ Object {
+ "term": Object {
+ "entity_id": "user:alice",
+ },
+ },
+ Object {
+ "exists": Object {
+ "field": "source.ip",
+ },
+ },
+ Object {
+ "range": Object {
+ "@timestamp": Object {
+ "gte": "now-90d",
+ "lt": 1778241600000,
+ },
+ },
+ },
+ ],
+ "must_not": Array [
+ Object {
+ "terms": Object {
+ "_tier": Array [
+ "data_cold",
+ "data_frozen",
+ ],
+ },
+ },
+ Object {
+ "terms": Object {
+ "source.ip": Array [
+ "evil-ip",
+ ],
+ },
+ },
+ ],
+ },
+ },
+ "runtime_mappings": Object {
+ "entity_id": Object {
+ "script": Object {
+ "source": "",
+ },
+ "type": "keyword",
+ },
+ },
+ "size": 0,
+}
+`;
diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/maintainers/behaviors/ml_anomaly_detection/constants.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/maintainers/behaviors/ml_anomaly_detection/constants.ts
new file mode 100644
index 0000000000000..705391404d05b
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/maintainers/behaviors/ml_anomaly_detection/constants.ts
@@ -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.
+ */
+
+export const ML_AD_MAINTAINER_ID = 'ml-anomaly-detection-jobs';
+export const ML_AD_MAINTAINER_INTERVAL = '1d';
+export const ML_AD_MAINTAINER_TIMEOUT = '10m';
+
+export const ML_AD_JOB_ENTITY_TYPES = ['user', 'host'] as const;
+
+// Window of anomaly records to inspect each run.
+export const ML_AD_LOOKBACK = '90d';
+
+// Safety check to prevent infinite loops in maintainer run
+export const MAX_ALLOWED_ITERS = 10000;
+
+// Page size when iterating entities from the entity store.
+export const ENTITY_PAGE_SIZE = 200;
+
+// Page size for paginating anomaly search results.
+export const ANOMALY_SEARCH_PAGE_SIZE = 1000;
+
+// Number of source documents to capture
+export const TOP_SOURCE_HITS = 3;
+
+// Number of baseline buckets to retain per anomaly
+export const BASELINE_BUCKET_SIZE = 3;
+
+export const ML_AD_DETAILS_INDEX_BASE = '.entity_analytics.ml-ad-jobs-latest';
+
+export const getMlAdDetailsIndexName = (namespace: string): string =>
+ `${ML_AD_DETAILS_INDEX_BASE}-${namespace}`;
diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/maintainers/behaviors/ml_anomaly_detection/details_index.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/maintainers/behaviors/ml_anomaly_detection/details_index.test.ts
new file mode 100644
index 0000000000000..00bd84df1a3a0
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/maintainers/behaviors/ml_anomaly_detection/details_index.test.ts
@@ -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 { elasticsearchServiceMock, loggingSystemMock } from '@kbn/core/server/mocks';
+import { ensureMlAdDetailsDataStream, ML_AD_DETAILS_MAPPING } from './details_index';
+import { getMlAdDetailsIndexName } from './constants';
+
+const esClient = elasticsearchServiceMock.createScopedClusterClient().asCurrentUser;
+let logger: ReturnType;
+
+describe('ensureMlAdDetailsDataStream', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ logger = loggingSystemMock.createLogger();
+ });
+
+ it('creates the index template and data stream when it does not exist', async () => {
+ esClient.indices.putIndexTemplate.mockResolvedValue({} as never);
+ esClient.indices.createDataStream.mockResolvedValue({} as never);
+
+ const dataStream = getMlAdDetailsIndexName('default');
+ const result = await ensureMlAdDetailsDataStream({ esClient, logger, namespace: 'default' });
+
+ expect(result).toBe(dataStream);
+ expect(esClient.indices.putIndexTemplate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ index_patterns: [expect.stringContaining('ml-ad-jobs-latest')],
+ data_stream: {},
+ template: expect.objectContaining({
+ mappings: ML_AD_DETAILS_MAPPING,
+ lifecycle: { data_retention: '90d' },
+ }),
+ })
+ );
+ expect(esClient.indices.createDataStream).toHaveBeenCalledWith({ name: dataStream });
+ });
+
+ it('uses the namespace to build the data stream name', async () => {
+ esClient.indices.putIndexTemplate.mockResolvedValue({} as never);
+ esClient.indices.createDataStream.mockResolvedValue({} as never);
+
+ const result = await ensureMlAdDetailsDataStream({ esClient, logger, namespace: 'my-space' });
+
+ expect(result).toBe(getMlAdDetailsIndexName('my-space'));
+ expect(esClient.indices.createDataStream).toHaveBeenCalledWith({
+ name: getMlAdDetailsIndexName('my-space'),
+ });
+ });
+
+ describe('error handling', () => {
+ it('swallows resource_already_exists_exception from concurrent creation', async () => {
+ esClient.indices.putIndexTemplate.mockResolvedValue({} as never);
+ esClient.indices.createDataStream.mockRejectedValue(
+ new Error('resource_already_exists_exception: data_stream already exists')
+ );
+
+ const result = await ensureMlAdDetailsDataStream({ esClient, logger, namespace: 'default' });
+
+ expect(result).toBe(getMlAdDetailsIndexName('default'));
+ expect(logger.warn).not.toHaveBeenCalled();
+ });
+
+ it('logs and swallows template creation errors', async () => {
+ esClient.indices.putIndexTemplate.mockRejectedValue(new Error('cluster_block_exception'));
+
+ const result = await ensureMlAdDetailsDataStream({ esClient, logger, namespace: 'default' });
+
+ expect(result).toBe(getMlAdDetailsIndexName('default'));
+ expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('cluster_block_exception'));
+ });
+
+ it('logs and swallows unexpected data stream creation errors', async () => {
+ esClient.indices.putIndexTemplate.mockResolvedValue({} as never);
+ esClient.indices.createDataStream.mockRejectedValue(new Error('cluster_block_exception'));
+
+ const result = await ensureMlAdDetailsDataStream({ esClient, logger, namespace: 'default' });
+
+ expect(result).toBe(getMlAdDetailsIndexName('default'));
+ expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('cluster_block_exception'));
+ });
+ });
+});
diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/maintainers/behaviors/ml_anomaly_detection/details_index.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/maintainers/behaviors/ml_anomaly_detection/details_index.ts
new file mode 100644
index 0000000000000..fa03ed6e88a0b
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/maintainers/behaviors/ml_anomaly_detection/details_index.ts
@@ -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 { ElasticsearchClient, Logger } from '@kbn/core/server';
+import type { MappingTypeMapping } from '@elastic/elasticsearch/lib/api/types';
+import { getMlAdDetailsIndexName, ML_AD_DETAILS_INDEX_BASE } from './constants';
+
+const ML_AD_DETAILS_INDEX_TEMPLATE_NAME = '.entity_analytics.ml-ad-jobs-latest-template';
+
+export const ML_AD_DETAILS_MAPPING: MappingTypeMapping = {
+ properties: {
+ '@timestamp': { type: 'date' },
+ entity: {
+ properties: {
+ id: { type: 'keyword' },
+ type: { type: 'keyword' },
+ },
+ },
+ anomaly: {
+ properties: {
+ _id: { type: 'keyword' },
+ job_id: { type: 'keyword' },
+ detector_index: { type: 'integer' },
+ timestamp: { type: 'date' },
+ record_score: { type: 'float' },
+ field_name: { type: 'keyword' },
+ actual: { type: 'double' },
+ typical: { type: 'double' },
+ by_field_name: { type: 'keyword' },
+ by_field_value: { type: 'keyword' },
+ over_field_name: { type: 'keyword' },
+ over_field_value: { type: 'keyword' },
+ partition_field_name: { type: 'keyword' },
+ partition_field_value: { type: 'keyword' },
+ },
+ },
+ baseline: {
+ type: 'object',
+ enabled: false,
+ },
+ },
+};
+
+interface EnsureMlAdDetailsDataStreamOpts {
+ esClient: ElasticsearchClient;
+ logger: Logger;
+ namespace: string;
+}
+
+export const ensureMlAdDetailsDataStream = async ({
+ esClient,
+ logger,
+ namespace,
+}: EnsureMlAdDetailsDataStreamOpts): Promise => {
+ const dataStream = getMlAdDetailsIndexName(namespace);
+
+ try {
+ await esClient.indices.putIndexTemplate({
+ name: ML_AD_DETAILS_INDEX_TEMPLATE_NAME,
+ index_patterns: [`${ML_AD_DETAILS_INDEX_BASE}-*`],
+ data_stream: {},
+ template: {
+ mappings: ML_AD_DETAILS_MAPPING,
+ lifecycle: {
+ data_retention: '90d',
+ },
+ },
+ });
+
+ try {
+ await esClient.indices.createDataStream({ name: dataStream });
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ if (!message.includes('resource_already_exists_exception')) {
+ throw error;
+ }
+ }
+ } catch (error) {
+ logger.warn(
+ `Error ensuring ML AD details data stream exists: ${
+ error instanceof Error ? error.message : String(error)
+ }`
+ );
+ }
+
+ return dataStream;
+};
diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/maintainers/behaviors/ml_anomaly_detection/enrich_and_persist.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/maintainers/behaviors/ml_anomaly_detection/enrich_and_persist.test.ts
new file mode 100644
index 0000000000000..19728fc0c5b18
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/maintainers/behaviors/ml_anomaly_detection/enrich_and_persist.test.ts
@@ -0,0 +1,435 @@
+/*
+ * 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 {
+ elasticsearchServiceMock,
+ loggingSystemMock,
+ savedObjectsClientMock,
+} from '@kbn/core/server/mocks';
+import type { MlPluginSetup } from '@kbn/ml-plugin/server';
+import type { EntityAnomalies } from './fetch_anomalies';
+import type { AnomalyHit } from './types';
+import { enrichAndPersistAnomalies } from './enrich_and_persist';
+import { makeAnomaly, makeBaselineBucket } from './test_helpers';
+
+jest.mock('./fetch_baseline_behavior', () => ({
+ fetchBaselineBehavior: jest.fn(),
+}));
+
+jest.mock('./constants', () => ({
+ ...jest.requireActual('./constants'),
+ getMlAdDetailsIndexName: jest.fn((ns: string) => `.ml-ad-details-${ns}`),
+}));
+
+import { fetchBaselineBehavior } from './fetch_baseline_behavior';
+import { getMlAdDetailsIndexName } from './constants';
+
+const mockFetchBaselineBehavior = fetchBaselineBehavior as jest.MockedFunction<
+ typeof fetchBaselineBehavior
+>;
+const mockGetMlAdDetailsIndexName = getMlAdDetailsIndexName as jest.MockedFunction<
+ typeof getMlAdDetailsIndexName
+>;
+
+const makeEntityAnomalies = (jobId: string, anomalies: AnomalyHit[]): EntityAnomalies => ({
+ [jobId]: { anomalies, baselineBehaviors: [] },
+});
+
+let logger: ReturnType;
+let esClient: ReturnType;
+let ml: MlPluginSetup;
+const soClient = savedObjectsClientMock.create();
+const abortSignal = new AbortController().signal;
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ logger = loggingSystemMock.createLogger();
+ esClient = elasticsearchServiceMock.createElasticsearchClient();
+ ml = {} as MlPluginSetup;
+ mockFetchBaselineBehavior.mockResolvedValue([]);
+ esClient.bulk.mockResolvedValue({ errors: false, items: [], took: 1 });
+});
+
+describe('enrichAndPersistAnomalies', () => {
+ it('returns early without calling fetchBaselineBehavior or bulk when anomaliesByEntity is empty', async () => {
+ await enrichAndPersistAnomalies({
+ anomaliesByEntity: new Map(),
+ abortSignal,
+ entityType: 'user',
+ esClient,
+ logger,
+ ml,
+ namespace: 'default',
+ soClient,
+ });
+
+ expect(mockFetchBaselineBehavior).not.toHaveBeenCalled();
+ expect(esClient.bulk).not.toHaveBeenCalled();
+ });
+
+ it('returns early without calling bulk when all jobs have empty anomaly arrays', async () => {
+ const anomaliesByEntity: Map = new Map([
+ ['user:alice', { 'security-job-1': { anomalies: [], baselineBehaviors: [] } }],
+ ]);
+
+ await enrichAndPersistAnomalies({
+ anomaliesByEntity,
+ abortSignal,
+ entityType: 'user',
+ esClient,
+ logger,
+ ml,
+ namespace: 'default',
+ soClient,
+ });
+
+ expect(esClient.bulk).not.toHaveBeenCalled();
+ });
+
+ it('calls fetchBaselineBehavior with the correct arguments for each entity and job', async () => {
+ const anomaly1 = makeAnomaly({ entityId: 'user:alice', jobId: 'security-job-1' });
+ const anomaly2 = makeAnomaly({ entityId: 'user:bob', jobId: 'security-job-2' });
+
+ const anomaliesByEntity: Map = new Map([
+ ['user:alice', makeEntityAnomalies('security-job-1', [anomaly1])],
+ ['user:bob', makeEntityAnomalies('security-job-2', [anomaly2])],
+ ]);
+
+ await enrichAndPersistAnomalies({
+ anomaliesByEntity,
+ abortSignal,
+ entityType: 'user',
+ esClient,
+ logger,
+ ml,
+ namespace: 'default',
+ soClient,
+ });
+
+ expect(mockFetchBaselineBehavior).toHaveBeenCalledTimes(2);
+ expect(mockFetchBaselineBehavior).toHaveBeenCalledWith({
+ anomalies: [anomaly1],
+ entityId: 'user:alice',
+ abortSignal,
+ entityType: 'user',
+ esClient,
+ jobId: 'security-job-1',
+ logger,
+ ml,
+ soClient,
+ });
+ expect(mockFetchBaselineBehavior).toHaveBeenCalledWith({
+ anomalies: [anomaly2],
+ entityId: 'user:bob',
+ abortSignal,
+ entityType: 'user',
+ esClient,
+ jobId: 'security-job-2',
+ logger,
+ ml,
+ soClient,
+ });
+ });
+
+ it('makes exactly one fetchBaselineBehavior call when an entity has multiple anomalies for the same job', async () => {
+ const anomaly1 = makeAnomaly({ entityId: 'user:alice', jobId: 'security-job-1' });
+ const anomaly2 = makeAnomaly({ entityId: 'user:alice', jobId: 'security-job-1' });
+
+ await enrichAndPersistAnomalies({
+ anomaliesByEntity: new Map([
+ ['user:alice', makeEntityAnomalies('security-job-1', [anomaly1, anomaly2])],
+ ]),
+ abortSignal,
+ entityType: 'user',
+ esClient,
+ logger,
+ ml,
+ namespace: 'default',
+ soClient,
+ });
+
+ expect(mockFetchBaselineBehavior).toHaveBeenCalledTimes(1);
+ expect(mockFetchBaselineBehavior).toHaveBeenCalledWith(
+ expect.objectContaining({ anomalies: [anomaly1, anomaly2] })
+ );
+ });
+
+ it('maps the anomaly and entity fields into the indexed document correctly', async () => {
+ const anomaly = makeAnomaly({
+ entityId: 'user:alice',
+ jobId: 'security-job-1',
+ detectorIndex: 2,
+ timestamp: 1778241600000,
+ recordScore: 88,
+ actual: 10,
+ typical: 2,
+ fieldName: 'process.name',
+ byFieldName: 'client.geo.name',
+ byFieldValue: 'Iran',
+ });
+
+ await enrichAndPersistAnomalies({
+ anomaliesByEntity: new Map([
+ ['user:alice', makeEntityAnomalies('security-job-1', [anomaly])],
+ ]),
+ abortSignal,
+ entityType: 'user',
+ esClient,
+ logger,
+ ml,
+ namespace: 'default',
+ soClient,
+ });
+
+ const bulkCall = esClient.bulk.mock.calls[0][0];
+ const indexedDoc = (bulkCall.operations as unknown[])[1] as Record;
+ expect(indexedDoc.entity).toEqual({ id: 'user:alice', type: 'user' });
+ expect(indexedDoc.anomaly).toEqual({
+ _id: 'anomaly-hit-1',
+ job_id: 'security-job-1',
+ detector_index: 2,
+ timestamp: 1778241600000,
+ record_score: 88,
+ field_name: 'process.name',
+ actual: 10,
+ typical: 2,
+ by_field_name: 'client.geo.name',
+ by_field_value: 'Iran',
+ over_field_name: undefined,
+ over_field_value: undefined,
+ partition_field_name: undefined,
+ partition_field_value: undefined,
+ });
+ });
+
+ it('attaches baseline behaviors from fetchBaselineBehavior to each anomaly', async () => {
+ const anomaly = makeAnomaly();
+ const baselines = [makeBaselineBucket({ value: 'US' }), makeBaselineBucket({ value: 'UK' })];
+ mockFetchBaselineBehavior.mockResolvedValueOnce(baselines);
+
+ await enrichAndPersistAnomalies({
+ anomaliesByEntity: new Map([
+ ['user:alice', makeEntityAnomalies('security-job-1', [anomaly])],
+ ]),
+ abortSignal,
+ entityType: 'user',
+ esClient,
+ logger,
+ ml,
+ namespace: 'default',
+ soClient,
+ });
+
+ const bulkCall = esClient.bulk.mock.calls[0][0];
+ const indexedDoc = (bulkCall.operations as unknown[])[1] as Record;
+ expect(indexedDoc.baseline).toEqual([
+ { value: 'US', doc_count: 100, top_hits: [] },
+ { value: 'UK', doc_count: 100, top_hits: [] },
+ ]);
+ });
+
+ it('uses an empty array for baseline when fetchBaselineBehavior returns null', async () => {
+ const anomaly = makeAnomaly();
+ mockFetchBaselineBehavior.mockResolvedValueOnce(null);
+
+ await enrichAndPersistAnomalies({
+ anomaliesByEntity: new Map([
+ ['user:alice', makeEntityAnomalies('security-job-1', [anomaly])],
+ ]),
+ abortSignal,
+ entityType: 'user',
+ esClient,
+ logger,
+ ml,
+ namespace: 'default',
+ soClient,
+ });
+
+ const bulkCall = esClient.bulk.mock.calls[0][0];
+ const indexedDoc = (bulkCall.operations as unknown[])[1] as Record;
+ expect(indexedDoc.baseline).toEqual([]);
+ });
+
+ it('bulk indexes to the correct index for the given namespace', async () => {
+ const anomaly = makeAnomaly();
+ mockGetMlAdDetailsIndexName.mockReturnValue('.ml-ad-details-my-namespace');
+
+ await enrichAndPersistAnomalies({
+ anomaliesByEntity: new Map([
+ ['user:alice', makeEntityAnomalies('security-job-1', [anomaly])],
+ ]),
+ abortSignal,
+ entityType: 'user',
+ esClient,
+ logger,
+ ml,
+ namespace: 'my-namespace',
+ soClient,
+ });
+
+ expect(mockGetMlAdDetailsIndexName).toHaveBeenCalledWith('my-namespace');
+ const bulkCall = esClient.bulk.mock.calls[0][0];
+ const indexOp = (bulkCall.operations as unknown[])[0] as { create: { _index: string } };
+ expect(indexOp.create._index).toBe('.ml-ad-details-my-namespace');
+ });
+
+ it('bulk indexes each anomaly with a @timestamp field', async () => {
+ const anomaly = makeAnomaly();
+ const before = Date.now();
+
+ await enrichAndPersistAnomalies({
+ anomaliesByEntity: new Map([
+ ['user:alice', makeEntityAnomalies('security-job-1', [anomaly])],
+ ]),
+ abortSignal,
+ entityType: 'user',
+ esClient,
+ logger,
+ ml,
+ namespace: 'default',
+ soClient,
+ });
+
+ const after = Date.now();
+ const bulkCall = esClient.bulk.mock.calls[0][0];
+ const indexedDoc = (bulkCall.operations as unknown[])[1] as Record;
+ const ts = new Date(indexedDoc['@timestamp'] as string).getTime();
+ expect(ts).toBeGreaterThanOrEqual(before);
+ expect(ts).toBeLessThanOrEqual(after);
+ });
+
+ it('bulk indexes one operation pair per anomaly across multiple entities and jobs', async () => {
+ const anomaly1 = makeAnomaly({ entityId: 'user:alice', jobId: 'security-job-1' });
+ const anomaly2 = makeAnomaly({ entityId: 'user:alice', jobId: 'security-job-2' });
+ const anomaly3 = makeAnomaly({ entityId: 'user:bob', jobId: 'security-job-1' });
+
+ const anomaliesByEntity: Map = new Map([
+ [
+ 'user:alice',
+ {
+ 'security-job-1': { anomalies: [anomaly1], baselineBehaviors: [] },
+ 'security-job-2': { anomalies: [anomaly2], baselineBehaviors: [] },
+ },
+ ],
+ ['user:bob', makeEntityAnomalies('security-job-1', [anomaly3])],
+ ]);
+
+ await enrichAndPersistAnomalies({
+ anomaliesByEntity,
+ abortSignal,
+ entityType: 'user',
+ esClient,
+ logger,
+ ml,
+ namespace: 'default',
+ soClient,
+ });
+
+ const bulkCall = esClient.bulk.mock.calls[0][0];
+ // 3 anomalies × 2 (index op + doc) = 6 operations
+ expect((bulkCall.operations as unknown[]).length).toBe(6);
+ });
+
+ it('calls bulk with refresh: false', async () => {
+ const anomaly = makeAnomaly();
+
+ await enrichAndPersistAnomalies({
+ anomaliesByEntity: new Map([
+ ['user:alice', makeEntityAnomalies('security-job-1', [anomaly])],
+ ]),
+ abortSignal,
+ entityType: 'user',
+ esClient,
+ logger,
+ ml,
+ namespace: 'default',
+ soClient,
+ });
+
+ expect(esClient.bulk).toHaveBeenCalledWith(expect.objectContaining({ refresh: false }));
+ });
+
+ it('logs a warning when the bulk response contains errors', async () => {
+ const anomaly = makeAnomaly();
+ const errorItem = { index: { error: { reason: 'mapper_exception' } } };
+ esClient.bulk.mockResolvedValueOnce({
+ errors: true,
+ items: [errorItem as never],
+ took: 1,
+ });
+
+ await enrichAndPersistAnomalies({
+ anomaliesByEntity: new Map([
+ ['user:alice', makeEntityAnomalies('security-job-1', [anomaly])],
+ ]),
+ abortSignal,
+ entityType: 'user',
+ esClient,
+ logger,
+ ml,
+ namespace: 'default',
+ soClient,
+ });
+
+ expect(logger.warn).toHaveBeenCalledWith(
+ expect.stringContaining('Bulk-index of enriched anomaly records returned errors')
+ );
+ });
+
+ it('still indexes all anomalies with empty baseline when one fetchBaselineBehavior call fails', async () => {
+ const anomaly1 = makeAnomaly({ entityId: 'user:alice', jobId: 'security-job-1' });
+ const anomaly2 = makeAnomaly({ entityId: 'user:bob', jobId: 'security-job-2' });
+ const baselines = [makeBaselineBucket({ value: 'US' })];
+ const fetchError = new Error('fetch failed');
+
+ mockFetchBaselineBehavior.mockResolvedValueOnce(baselines).mockRejectedValueOnce(fetchError);
+
+ await enrichAndPersistAnomalies({
+ anomaliesByEntity: new Map([
+ ['user:alice', makeEntityAnomalies('security-job-1', [anomaly1])],
+ ['user:bob', makeEntityAnomalies('security-job-2', [anomaly2])],
+ ]),
+ abortSignal,
+ entityType: 'user',
+ esClient,
+ logger,
+ ml,
+ namespace: 'default',
+ soClient,
+ });
+
+ expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('fetch failed'));
+
+ const bulkCall = esClient.bulk.mock.calls[0][0];
+ // both anomalies still indexed
+ expect((bulkCall.operations as unknown[]).length).toBe(4);
+
+ const aliceDoc = (bulkCall.operations as unknown[])[1] as Record;
+ const bobDoc = (bulkCall.operations as unknown[])[3] as Record;
+ expect(aliceDoc.baseline).toEqual([{ value: 'US', doc_count: 100, top_hits: [] }]);
+ expect(bobDoc.baseline).toEqual([]);
+ });
+
+ it('does not log a warning when the bulk response has no errors', async () => {
+ const anomaly = makeAnomaly();
+
+ await enrichAndPersistAnomalies({
+ anomaliesByEntity: new Map([
+ ['user:alice', makeEntityAnomalies('security-job-1', [anomaly])],
+ ]),
+ abortSignal,
+ entityType: 'user',
+ esClient,
+ logger,
+ ml,
+ namespace: 'default',
+ soClient,
+ });
+
+ expect(logger.warn).not.toHaveBeenCalled();
+ });
+});
diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/maintainers/behaviors/ml_anomaly_detection/enrich_and_persist.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/maintainers/behaviors/ml_anomaly_detection/enrich_and_persist.ts
new file mode 100644
index 0000000000000..65c0a19d60c44
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/maintainers/behaviors/ml_anomaly_detection/enrich_and_persist.ts
@@ -0,0 +1,109 @@
+/*
+ * 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 { ElasticsearchClient, Logger, SavedObjectsClientContract } from '@kbn/core/server';
+import type { EntityType } from '@kbn/entity-store/common';
+import type { MlPluginSetup } from '@kbn/ml-plugin/server';
+import type { EntityAnomalies } from './fetch_anomalies';
+import { fetchBaselineBehavior } from './fetch_baseline_behavior';
+import { getMlAdDetailsIndexName } from './constants';
+import type { EnrichedAnomalyRecord } from './types';
+
+interface EnrichAndPersistAnomaliesOpts {
+ abortSignal: AbortSignal;
+ anomaliesByEntity: Map;
+ entityType: EntityType;
+ esClient: ElasticsearchClient;
+ logger: Logger;
+ ml: MlPluginSetup;
+ namespace: string;
+ soClient: SavedObjectsClientContract;
+}
+export const enrichAndPersistAnomalies = async ({
+ abortSignal,
+ anomaliesByEntity,
+ entityType,
+ esClient,
+ logger,
+ ml,
+ namespace,
+ soClient,
+}: EnrichAndPersistAnomaliesOpts) => {
+ const fetchTasks = [...anomaliesByEntity.entries()].flatMap(([entityId, entityAnomalies]) =>
+ Object.entries(entityAnomalies).map(([jobId, jobData]) => ({ entityId, jobId, jobData }))
+ );
+
+ const fetchResults = await Promise.all(
+ fetchTasks.map(({ entityId, jobId, jobData }) =>
+ fetchBaselineBehavior({
+ abortSignal,
+ anomalies: jobData.anomalies,
+ entityId,
+ entityType,
+ esClient,
+ jobId,
+ logger,
+ ml,
+ soClient,
+ })
+ .then((baselinesBehaviors) => ({ entityId, jobId, jobData, baselinesBehaviors }))
+ .catch((err) => {
+ logger.warn(
+ `Failed to fetch baseline behavior for entity ${entityId}, job ${jobId}: ${err}`
+ );
+ return { entityId, jobId, jobData, baselinesBehaviors: null };
+ })
+ )
+ );
+
+ const enrichedRecords: EnrichedAnomalyRecord[] = fetchResults.flatMap(
+ ({ entityId, jobId, jobData, baselinesBehaviors }) =>
+ jobData.anomalies.map((anomaly) => ({
+ entity: {
+ id: entityId,
+ type: entityType,
+ },
+ anomaly: {
+ _id: anomaly._id,
+ job_id: jobId,
+ detector_index: anomaly.detectorIndex,
+ timestamp: anomaly.timestamp,
+ record_score: anomaly.recordScore,
+ field_name: anomaly.fieldName,
+ actual: anomaly.actual,
+ typical: anomaly.typical,
+ by_field_name: anomaly.byFieldName,
+ by_field_value: anomaly.byFieldValue,
+ over_field_name: anomaly.overFieldName,
+ over_field_value: anomaly.overFieldValue,
+ partition_field_name: anomaly.partitionFieldName,
+ partition_field_value: anomaly.partitionFieldValue,
+ },
+ baseline: (baselinesBehaviors ?? []).map((bb) => ({
+ value: bb.value,
+ doc_count: bb.doc_count,
+ top_hits: bb.topHits,
+ })),
+ }))
+ );
+
+ if (enrichedRecords.length === 0) return;
+
+ const detailsIndex = getMlAdDetailsIndexName(namespace);
+ const operations = enrichedRecords.flatMap((record) => [
+ { create: { _index: detailsIndex } },
+ { '@timestamp': new Date().toISOString(), ...record },
+ ]);
+
+ const resp = await esClient.bulk({ operations, refresh: false });
+ if (resp.errors) {
+ const errors = resp.items.filter((item) => Object.values(item)[0]?.error);
+ logger.warn(
+ `Bulk-index of enriched anomaly records returned errors. ${JSON.stringify(errors)}`
+ );
+ }
+};
diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/maintainers/behaviors/ml_anomaly_detection/fetch_anomalies.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/maintainers/behaviors/ml_anomaly_detection/fetch_anomalies.test.ts
new file mode 100644
index 0000000000000..74faf2d8858f3
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/maintainers/behaviors/ml_anomaly_detection/fetch_anomalies.test.ts
@@ -0,0 +1,576 @@
+/*
+ * 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, savedObjectsClientMock } from '@kbn/core/server/mocks';
+import type { MlPluginSetup } from '@kbn/ml-plugin/server';
+import { fetchAnomaliesForEntityBatch, streamAnomaliesForEntityBatch } from './fetch_anomalies';
+import { makeHit, makeResponse } from './test_helpers';
+import type { AnomalyHit } from './types';
+
+const collectPages = async (gen: AsyncGenerator): Promise => {
+ const results: AnomalyHit[] = [];
+ for await (const page of gen) {
+ results.push(...page);
+ }
+ return results;
+};
+
+// Use small constants so pagination / loop-guard tests don't require thousands of hits.
+jest.mock('./constants', () => ({
+ ...jest.requireActual('./constants'),
+ ANOMALY_SEARCH_PAGE_SIZE: 2,
+ MAX_ALLOWED_ITERS: 3,
+ DEFAULT_ANOMALY_THRESHOLD: 50,
+ ML_AD_LOOKBACK: '1h',
+}));
+
+jest.mock('./get_security_ml_job_ids', () => ({
+ getSecurityMlJobIds: jest.fn().mockResolvedValue(['security-job-1', 'security-job-2']),
+}));
+
+jest.mock('@kbn/entity-store/common/euid_helpers', () => ({
+ euid: {
+ painless: {
+ getEuidRuntimeMapping: jest.fn().mockReturnValue({
+ lang: 'painless',
+ source: 'mock-euid-script',
+ }),
+ },
+ },
+}));
+
+// ---------------------------------------------------------------------------
+// Setup
+// ---------------------------------------------------------------------------
+
+let mockMlAnomalySearch: jest.Mock;
+let mockMl: MlPluginSetup;
+let logger: ReturnType;
+const soClient = savedObjectsClientMock.create();
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ logger = loggingSystemMock.createLogger();
+ mockMlAnomalySearch = jest.fn().mockResolvedValue(makeResponse([]));
+ mockMl = {
+ mlSystemProvider: jest.fn().mockReturnValue({ mlAnomalySearch: mockMlAnomalySearch }),
+ } as unknown as MlPluginSetup;
+});
+
+describe('streamAnomaliesForEntityBatch', () => {
+ it('returns empty array without querying ML when entityIds is empty', async () => {
+ const result = await collectPages(
+ streamAnomaliesForEntityBatch({
+ anomalyThreshold: 50,
+ entityType: 'user',
+ entityIds: [],
+ logger,
+ ml: mockMl,
+ soClient,
+ })
+ );
+
+ expect(result).toEqual([]);
+ expect(mockMlAnomalySearch).not.toHaveBeenCalled();
+ });
+
+ it('sends the correct query filters to mlAnomalySearch', async () => {
+ const { euid } = jest.requireMock('@kbn/entity-store/common/euid_helpers');
+
+ await collectPages(
+ streamAnomaliesForEntityBatch({
+ anomalyThreshold: 50,
+ entityType: 'user',
+ entityIds: ['user:alice', 'user:bob'],
+ logger,
+ ml: mockMl,
+ soClient,
+ })
+ );
+
+ expect(euid.painless.getEuidRuntimeMapping).toHaveBeenCalledWith('user');
+ expect(mockMlAnomalySearch).toHaveBeenCalledWith(
+ {
+ fields: ['entity_id'],
+ query: {
+ bool: {
+ filter: [
+ { term: { result_type: 'record' } },
+ { terms: { entity_id: ['user:alice', 'user:bob'] } },
+ { term: { is_interim: false } },
+ { range: { record_score: { gte: 50 } } },
+ { range: { timestamp: { gte: 'now-1h' } } },
+ ],
+ },
+ },
+ runtime_mappings: {
+ entity_id: { lang: 'painless', source: 'mock-euid-script' },
+ },
+ size: 2,
+ sort: [{ timestamp: 'asc' }, { job_id: 'asc' }, { detector_index: 'asc' }],
+ },
+ []
+ );
+ });
+
+ it('maps a full hit to an AnomalyHit correctly', async () => {
+ mockMlAnomalySearch.mockResolvedValueOnce(
+ makeResponse([
+ makeHit({
+ entityId: 'user:alice',
+ jobId: 'security-job-1',
+ detectorIndex: 2,
+ timestamp: 1778241600000,
+ recordScore: 88,
+ actual: [10],
+ typical: [2],
+ }),
+ ])
+ );
+
+ const result = await collectPages(
+ streamAnomaliesForEntityBatch({
+ anomalyThreshold: 50,
+ entityType: 'user',
+ entityIds: ['user:alice'],
+ logger,
+ ml: mockMl,
+ soClient,
+ })
+ );
+
+ expect(result).toEqual([
+ {
+ _id: 'hit-1',
+ entityId: 'user:alice',
+ jobId: 'security-job-1',
+ detectorIndex: 2,
+ timestamp: 1778241600000,
+ recordScore: 88,
+ actual: 10,
+ typical: 2,
+ fieldName: undefined,
+ byFieldName: 'client.geo.name',
+ byFieldValue: 'Iran',
+ overFieldName: undefined,
+ overFieldValue: undefined,
+ partitionFieldName: 'host.name',
+ partitionFieldValue: 'web-01',
+ },
+ ]);
+ });
+
+ it('skips hits where actual, typical or detector_index is missing', async () => {
+ for (const field of ['actual', 'typical', 'detector_index'] as const) {
+ jest.clearAllMocks();
+ mockMlAnomalySearch = jest.fn().mockResolvedValue(makeResponse([]));
+ mockMl = {
+ mlSystemProvider: jest.fn().mockReturnValue({ mlAnomalySearch: mockMlAnomalySearch }),
+ } as unknown as MlPluginSetup;
+
+ const hit = makeHit({ entityId: 'host:web-01' });
+ delete (hit._source as Record)[field];
+ mockMlAnomalySearch.mockResolvedValueOnce(makeResponse([hit]));
+
+ const result = await collectPages(
+ streamAnomaliesForEntityBatch({
+ anomalyThreshold: 50,
+ entityType: 'host',
+ entityIds: ['host:web-01'],
+ logger,
+ ml: mockMl,
+ soClient,
+ })
+ );
+
+ expect(result).toEqual([]);
+ }
+ });
+
+ it('skips hits where entity_id field is missing', async () => {
+ mockMlAnomalySearch.mockResolvedValueOnce(makeResponse([makeHit({ noEntityId: true })]));
+
+ const result = await collectPages(
+ streamAnomaliesForEntityBatch({
+ anomalyThreshold: 50,
+ entityType: 'user',
+ entityIds: ['user:alice'],
+ logger,
+ ml: mockMl,
+ soClient,
+ })
+ );
+
+ expect(result).toEqual([]);
+ });
+
+ it('skips hits where _source is missing', async () => {
+ mockMlAnomalySearch.mockResolvedValueOnce(makeResponse([makeHit({ noSource: true })]));
+
+ const result = await collectPages(
+ streamAnomaliesForEntityBatch({
+ anomalyThreshold: 50,
+ entityType: 'user',
+ entityIds: ['user:alice'],
+ logger,
+ ml: mockMl,
+ soClient,
+ })
+ );
+
+ expect(result).toEqual([]);
+ });
+
+ it('skips hits whose job_id is not in the security job ids list', async () => {
+ mockMlAnomalySearch.mockResolvedValueOnce(
+ makeResponse([makeHit({ jobId: 'non-security-job' })])
+ );
+
+ const result = await collectPages(
+ streamAnomaliesForEntityBatch({
+ anomalyThreshold: 50,
+ entityType: 'user',
+ entityIds: ['user:alice'],
+ logger,
+ ml: mockMl,
+ soClient,
+ })
+ );
+
+ expect(result).toEqual([]);
+ });
+
+ it('returns empty array when there are no matching hits', async () => {
+ mockMlAnomalySearch.mockResolvedValueOnce(makeResponse([]));
+
+ const result = await collectPages(
+ streamAnomaliesForEntityBatch({
+ anomalyThreshold: 50,
+ entityType: 'user',
+ entityIds: ['user:alice'],
+ logger,
+ ml: mockMl,
+ soClient,
+ })
+ );
+
+ expect(result).toEqual([]);
+ });
+
+ it('accumulates hits from multiple pages', async () => {
+ const page1 = [
+ makeHit({ entityId: 'user:alice', timestamp: 1000, sort: [1000, 'security-job-1', 0] }),
+ makeHit({ entityId: 'user:bob', timestamp: 2000, sort: [2000, 'security-job-1', 0] }),
+ ];
+ const page2 = [
+ makeHit({ entityId: 'user:carol', timestamp: 3000, sort: [3000, 'security-job-1', 0] }),
+ ];
+
+ mockMlAnomalySearch
+ .mockResolvedValueOnce(makeResponse(page1))
+ .mockResolvedValueOnce(makeResponse(page2));
+
+ const result = await collectPages(
+ streamAnomaliesForEntityBatch({
+ anomalyThreshold: 50,
+ entityType: 'user',
+ entityIds: ['user:alice', 'user:bob', 'user:carol'],
+ logger,
+ ml: mockMl,
+ soClient,
+ })
+ );
+
+ expect(mockMlAnomalySearch).toHaveBeenCalledTimes(2);
+
+ const [firstBody] = mockMlAnomalySearch.mock.calls[0];
+ expect(firstBody).not.toHaveProperty('search_after');
+ const [secondBody] = mockMlAnomalySearch.mock.calls[1];
+ expect(secondBody.search_after).toEqual([2000, 'security-job-1', 0]);
+
+ expect(result).toHaveLength(3);
+ expect(result.map((r) => r.entityId)).toEqual(['user:alice', 'user:bob', 'user:carol']);
+ });
+
+ it('stops paginating after MAX_ALLOWED_ITERS pages regardless of page size', async () => {
+ // MAX_ALLOWED_ITERS is mocked to 3; the guard is `iters++ > MAX_ALLOWED_ITERS`,
+ // so iterations 0–3 each query ES (4 total) before iteration 4 triggers the break.
+ const fullPage = [
+ makeHit({ entityId: 'user:alice', sort: [1000, 'job', 0] }),
+ makeHit({ entityId: 'user:bob', sort: [2000, 'job', 0] }),
+ ];
+ mockMlAnomalySearch.mockResolvedValue(makeResponse(fullPage));
+
+ const result = await collectPages(
+ streamAnomaliesForEntityBatch({
+ anomalyThreshold: 50,
+ entityType: 'user',
+ entityIds: ['user:alice', 'user:bob'],
+ logger,
+ ml: mockMl,
+ soClient,
+ })
+ );
+
+ // (MAX_ALLOWED_ITERS + 1) iterations × PAGE_SIZE hits = 4 × 2 = 8 hits collected before breaking
+ expect(mockMlAnomalySearch).toHaveBeenCalledTimes(4);
+ expect(result).toHaveLength(8);
+ expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining('max iterations reached'));
+ });
+
+ it('logs a warning and stops iterating when mlAnomalySearch throws', async () => {
+ mockMlAnomalySearch.mockRejectedValueOnce(new Error('ES cluster unavailable'));
+
+ const result = await collectPages(
+ streamAnomaliesForEntityBatch({
+ anomalyThreshold: 50,
+ entityType: 'host',
+ entityIds: ['host:web-01'],
+ logger,
+ ml: mockMl,
+ soClient,
+ })
+ );
+
+ expect(result).toEqual([]);
+ expect(logger.warn).toHaveBeenCalledWith(
+ `Error encountered searching for anomalies for entity type "host": ES cluster unavailable`
+ );
+ });
+});
+
+describe('fetchAnomaliesForEntityBatch', () => {
+ it('returns an empty map when entityIds is empty', async () => {
+ const result = await fetchAnomaliesForEntityBatch({
+ anomalyThreshold: 50,
+ entityType: 'user',
+ entityIds: [],
+ logger,
+ ml: mockMl,
+ soClient,
+ });
+
+ expect(result.size).toBe(0);
+ expect(mockMlAnomalySearch).not.toHaveBeenCalled();
+ });
+
+ it('returns an empty map when there are no matching anomalies', async () => {
+ mockMlAnomalySearch.mockResolvedValueOnce(makeResponse([]));
+
+ const result = await fetchAnomaliesForEntityBatch({
+ anomalyThreshold: 50,
+ entityType: 'user',
+ entityIds: ['user:alice'],
+ logger,
+ ml: mockMl,
+ soClient,
+ });
+
+ expect(result.size).toBe(0);
+ });
+
+ it('groups anomalies by entityId and jobId', async () => {
+ mockMlAnomalySearch.mockResolvedValueOnce(
+ makeResponse([
+ makeHit({ id: '1', entityId: 'user:alice', jobId: 'security-job-1', recordScore: 80 }),
+ makeHit({ id: '2', entityId: 'user:alice', jobId: 'security-job-2', recordScore: 70 }),
+ ])
+ );
+
+ const result = await fetchAnomaliesForEntityBatch({
+ anomalyThreshold: 50,
+ entityType: 'user',
+ entityIds: ['user:alice'],
+ logger,
+ ml: mockMl,
+ soClient,
+ });
+
+ expect(result.size).toBe(1);
+ const aliceAnomalies = result.get('user:alice');
+ expect(Object.keys(aliceAnomalies ?? {})).toEqual(['security-job-1', 'security-job-2']);
+ expect(aliceAnomalies?.['security-job-1'].anomalies).toHaveLength(1);
+ expect(aliceAnomalies?.['security-job-1'].anomalies[0]).toEqual({
+ _id: '1',
+ entityId: 'user:alice',
+ jobId: 'security-job-1',
+ detectorIndex: 0,
+ timestamp: 1778241600000,
+ recordScore: 80,
+ actual: 5,
+ typical: 1,
+ fieldName: undefined,
+ byFieldName: 'client.geo.name',
+ byFieldValue: 'Iran',
+ overFieldName: undefined,
+ overFieldValue: undefined,
+ partitionFieldName: 'host.name',
+ partitionFieldValue: 'web-01',
+ });
+ expect(aliceAnomalies?.['security-job-2'].anomalies).toHaveLength(1);
+ expect(aliceAnomalies?.['security-job-2'].anomalies[0]).toEqual({
+ _id: '2',
+ entityId: 'user:alice',
+ jobId: 'security-job-2',
+ detectorIndex: 0,
+ timestamp: 1778241600000,
+ recordScore: 70,
+ actual: 5,
+ typical: 1,
+ fieldName: undefined,
+ byFieldName: 'client.geo.name',
+ byFieldValue: 'Iran',
+ overFieldName: undefined,
+ overFieldValue: undefined,
+ partitionFieldName: 'host.name',
+ partitionFieldValue: 'web-01',
+ });
+ });
+
+ it('collects multiple anomaly records under the same entityId and jobId', async () => {
+ mockMlAnomalySearch.mockResolvedValueOnce(
+ makeResponse([
+ makeHit({
+ id: '1',
+ entityId: 'user:alice',
+ jobId: 'security-job-1',
+ timestamp: 1000,
+ recordScore: 60,
+ }),
+ makeHit({
+ id: '2',
+ entityId: 'user:alice',
+ jobId: 'security-job-1',
+ timestamp: 2000,
+ recordScore: 90,
+ }),
+ ])
+ );
+
+ const result = await fetchAnomaliesForEntityBatch({
+ anomalyThreshold: 50,
+ entityType: 'user',
+ entityIds: ['user:alice'],
+ logger,
+ ml: mockMl,
+ soClient,
+ });
+
+ expect(result.get('user:alice')?.['security-job-1'].anomalies).toHaveLength(2);
+ expect(result.get('user:alice')?.['security-job-1'].anomalies).toEqual([
+ {
+ _id: '1',
+ entityId: 'user:alice',
+ jobId: 'security-job-1',
+ detectorIndex: 0,
+ timestamp: 1000,
+ recordScore: 60,
+ actual: 5,
+ typical: 1,
+ fieldName: undefined,
+ byFieldName: 'client.geo.name',
+ byFieldValue: 'Iran',
+ overFieldName: undefined,
+ overFieldValue: undefined,
+ partitionFieldName: 'host.name',
+ partitionFieldValue: 'web-01',
+ },
+ {
+ _id: '2',
+ entityId: 'user:alice',
+ jobId: 'security-job-1',
+ detectorIndex: 0,
+ timestamp: 2000,
+ recordScore: 90,
+ actual: 5,
+ typical: 1,
+ fieldName: undefined,
+ byFieldName: 'client.geo.name',
+ byFieldValue: 'Iran',
+ overFieldName: undefined,
+ overFieldValue: undefined,
+ partitionFieldName: 'host.name',
+ partitionFieldValue: 'web-01',
+ },
+ ]);
+ });
+
+ it('accumulates anomalies for multiple entities across pages', async () => {
+ const page1 = [
+ makeHit({
+ entityId: 'user:alice',
+ jobId: 'security-job-1',
+ sort: [1000, 'security-job-1', 0],
+ }),
+ makeHit({ entityId: 'user:bob', jobId: 'security-job-1', sort: [2000, 'security-job-1', 0] }),
+ ];
+ const page2 = [makeHit({ entityId: 'user:alice', jobId: 'security-job-2' })];
+
+ mockMlAnomalySearch
+ .mockResolvedValueOnce(makeResponse(page1))
+ .mockResolvedValueOnce(makeResponse(page2));
+
+ const result = await fetchAnomaliesForEntityBatch({
+ anomalyThreshold: 50,
+ entityType: 'user',
+ entityIds: ['user:alice', 'user:bob'],
+ logger,
+ ml: mockMl,
+ soClient,
+ });
+
+ expect(result.size).toBe(2);
+ expect(Object.keys(result.get('user:alice') ?? {})).toEqual(
+ expect.arrayContaining(['security-job-1', 'security-job-2'])
+ );
+ expect(result.get('user:bob')?.['security-job-1'].anomalies).toHaveLength(1);
+ });
+
+ it('merges anomalies for the same entity and job across pages', async () => {
+ const page1 = [
+ makeHit({
+ entityId: 'user:alice',
+ jobId: 'security-job-1',
+ timestamp: 1000,
+ recordScore: 60,
+ sort: [1000, 'security-job-1', 0],
+ }),
+ makeHit({
+ entityId: 'user:alice',
+ jobId: 'security-job-1',
+ timestamp: 2000,
+ recordScore: 70,
+ sort: [2000, 'security-job-1', 0],
+ }),
+ ];
+ const page2 = [
+ makeHit({
+ entityId: 'user:alice',
+ jobId: 'security-job-1',
+ timestamp: 3000,
+ recordScore: 80,
+ }),
+ ];
+
+ mockMlAnomalySearch
+ .mockResolvedValueOnce(makeResponse(page1))
+ .mockResolvedValueOnce(makeResponse(page2));
+
+ const result = await fetchAnomaliesForEntityBatch({
+ anomalyThreshold: 50,
+ entityType: 'user',
+ entityIds: ['user:alice'],
+ logger,
+ ml: mockMl,
+ soClient,
+ });
+
+ const anomalies = result.get('user:alice')?.['security-job-1'].anomalies;
+ expect(anomalies).toHaveLength(3);
+ expect(anomalies?.map((a) => a.recordScore)).toEqual([60, 70, 80]);
+ });
+});
diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/maintainers/behaviors/ml_anomaly_detection/fetch_anomalies.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/maintainers/behaviors/ml_anomaly_detection/fetch_anomalies.ts
new file mode 100644
index 0000000000000..0b99735ef844e
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/maintainers/behaviors/ml_anomaly_detection/fetch_anomalies.ts
@@ -0,0 +1,181 @@
+/*
+ * 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 { KibanaRequest, Logger, SavedObjectsClientContract } from '@kbn/core/server';
+import type { EntityType } from '@kbn/entity-store/common';
+import type { MlPluginSetup } from '@kbn/ml-plugin/server';
+import { euid } from '@kbn/entity-store/common/euid_helpers';
+import { ANOMALY_SEARCH_PAGE_SIZE, MAX_ALLOWED_ITERS, ML_AD_LOOKBACK } from './constants';
+import { getSecurityMlJobIds } from './get_security_ml_job_ids';
+import type { AnomalyHit } from './types';
+
+interface RawAnomalyRecord {
+ _id?: string;
+ timestamp: number;
+ job_id: string;
+ detector_index: number;
+ record_score: number;
+ field_name?: string;
+ by_field_name?: string;
+ by_field_value?: string;
+ over_field_name?: string;
+ over_field_value?: string;
+ partition_field_name?: string;
+ partition_field_value?: string;
+ actual?: number[];
+ typical?: number[];
+}
+
+interface StreamAnomaliesForEntityBatchOpts {
+ anomalyThreshold: number;
+ entityType: EntityType;
+ entityIds: string[];
+ logger: Logger;
+ ml: MlPluginSetup;
+ soClient: SavedObjectsClientContract;
+}
+
+export async function* streamAnomaliesForEntityBatch({
+ anomalyThreshold,
+ entityType,
+ entityIds,
+ logger,
+ ml,
+ soClient,
+}: StreamAnomaliesForEntityBatchOpts): AsyncGenerator {
+ if (entityIds.length === 0) return;
+
+ const mlSystem = ml.mlSystemProvider({} as KibanaRequest, soClient);
+ const jobIds = await getSecurityMlJobIds({ ml, soClient });
+ let searchAfter: unknown[] | undefined;
+ let iters = 0;
+
+ do {
+ if (iters++ > MAX_ALLOWED_ITERS) {
+ logger.debug(
+ `Maintainer run short-circuited during processing of entity type "${entityType}" - max iterations reached`
+ );
+ break;
+ }
+ try {
+ const resp = await mlSystem.mlAnomalySearch(
+ {
+ size: ANOMALY_SEARCH_PAGE_SIZE,
+ runtime_mappings: {
+ entity_id: euid.painless.getEuidRuntimeMapping(entityType),
+ },
+ fields: ['entity_id'],
+ query: {
+ bool: {
+ filter: [
+ { term: { result_type: 'record' } },
+ { terms: { entity_id: entityIds } },
+ { term: { is_interim: false } },
+ { range: { record_score: { gte: anomalyThreshold } } },
+ { range: { timestamp: { gte: `now-${ML_AD_LOOKBACK}` } } },
+ ],
+ },
+ },
+ sort: [{ timestamp: 'asc' }, { job_id: 'asc' }, { detector_index: 'asc' }],
+ ...(searchAfter ? { search_after: searchAfter } : {}),
+ },
+ []
+ );
+
+ const hits = resp.hits.hits;
+ const page = hits
+ .filter(
+ (hit): hit is typeof hit & { _source: Required } =>
+ hit._id != null &&
+ hit._source?.actual?.[0] != null &&
+ hit._source?.typical?.[0] != null &&
+ hit._source?.detector_index != null &&
+ hit._source?.job_id != null &&
+ jobIds.includes(hit._source.job_id) &&
+ (hit.fields?.entity_id as string[] | undefined)?.[0] != null
+ )
+ .map((hit) => {
+ const id = hit._id;
+ const src = hit._source;
+ const entityId = (hit.fields?.entity_id as string[] | undefined)?.[0];
+ if (!id || !src || !entityId) return undefined;
+ return {
+ _id: id,
+ entityId,
+ jobId: src.job_id,
+ detectorIndex: src.detector_index,
+ timestamp: src.timestamp,
+ recordScore: src.record_score,
+ actual: src.actual[0],
+ typical: src.typical[0],
+ fieldName: src.field_name,
+ byFieldName: src.by_field_name,
+ byFieldValue: src.by_field_value,
+ overFieldName: src.over_field_name,
+ overFieldValue: src.over_field_value,
+ partitionFieldName: src.partition_field_name,
+ partitionFieldValue: src.partition_field_value,
+ };
+ })
+ .filter((hit) => hit != null);
+
+ if (page.length > 0) {
+ yield page;
+ }
+
+ if (hits.length < ANOMALY_SEARCH_PAGE_SIZE) break;
+ searchAfter = hits[hits.length - 1].sort as unknown[];
+ } catch (error) {
+ logger.warn(
+ `Error encountered searching for anomalies for entity type "${entityType}": ${
+ error instanceof Error ? error.message : String(error)
+ }`
+ );
+ searchAfter = undefined;
+ }
+ } while (searchAfter != null);
+}
+
+export interface EntityAnomalies {
+ [jobId: string]: {
+ anomalies: AnomalyHit[];
+ baselineBehaviors: string[];
+ };
+}
+
+/**
+ * Fetches all anomaly records for a batch of entity IDs and returns them grouped by entity and job.
+ *
+ * Result shape:
+ * {
+ * "user:alice": {
+ * "job-A": { anomalies: [...], baselineBehaviors: [...] },
+ * "job-B": { anomalies: [...], baselineBehaviors: [...] },
+ * },
+ * "user:bob": { ... },
+ * }
+ *
+ * Anomalies are sorted in ascending order by timestamp,
+ */
+export async function fetchAnomaliesForEntityBatch(
+ opts: StreamAnomaliesForEntityBatchOpts
+): Promise