diff --git a/src/platform/packages/shared/kbn-workflows/managed/definitions/alertzero/floor_alert_triage.ts b/src/platform/packages/shared/kbn-workflows/managed/definitions/alertzero/floor_alert_triage.ts index 6bb8b10a9b63b..2b9e2bfbbe4d7 100644 --- a/src/platform/packages/shared/kbn-workflows/managed/definitions/alertzero/floor_alert_triage.ts +++ b/src/platform/packages/shared/kbn-workflows/managed/definitions/alertzero/floor_alert_triage.ts @@ -19,7 +19,7 @@ export const ALERTZERO_WORKER_FLOOR_ALERT_TRIAGE_WORKFLOW = { id: ALERTZERO_WORKER_FLOOR_ALERT_TRIAGE_WORKFLOW_ID, management: ALERTZERO_WORKER_MANAGEMENT, pluginId: ALERTZERO_MANAGED_WORKFLOW_PLUGIN_ID, - version: 1, + version: 2, yamlTemplate: (values: CommonWorkerTemplateValues): string => renderCommonWorkerYaml(FLOOR_ALERT_TRIAGE_YAML, values), } as const satisfies ManagedWorkflowDefinition; diff --git a/src/platform/packages/shared/kbn-workflows/managed/definitions/alertzero/floor_alert_triage.yaml b/src/platform/packages/shared/kbn-workflows/managed/definitions/alertzero/floor_alert_triage.yaml index f8adc6c36f8b4..49b9359410c37 100644 --- a/src/platform/packages/shared/kbn-workflows/managed/definitions/alertzero/floor_alert_triage.yaml +++ b/src/platform/packages/shared/kbn-workflows/managed/definitions/alertzero/floor_alert_triage.yaml @@ -21,6 +21,9 @@ steps: configuration_overrides: skill_ids: - alert-analysis + tools: + - tool_ids: + - security.alertzero.actions.list message: > Triage the security alert context below. Prefer recall over precision when unsure. Return a structured diff --git a/src/platform/packages/shared/kbn-workflows/managed/managed_workflow_definitions.test.ts b/src/platform/packages/shared/kbn-workflows/managed/managed_workflow_definitions.test.ts index 66a46aaecf30b..5f07ad96fc750 100644 --- a/src/platform/packages/shared/kbn-workflows/managed/managed_workflow_definitions.test.ts +++ b/src/platform/packages/shared/kbn-workflows/managed/managed_workflow_definitions.test.ts @@ -151,7 +151,7 @@ function createContentFingerprint(content: string): string { } it.each([ - [ALERTZERO_WORKER_FLOOR_ALERT_TRIAGE_WORKFLOW_ID, FLOOR_ALERT_TRIAGE_YAML, '1:d6a82eff'], + [ALERTZERO_WORKER_FLOOR_ALERT_TRIAGE_WORKFLOW_ID, FLOOR_ALERT_TRIAGE_YAML, '2:275b444e'], [ALERTZERO_WORKER_FLOOR_ATTACK_DISCOVERY_WORKFLOW_ID, FLOOR_ATTACK_DISCOVERY_YAML, '2:d13818a0'], [ ALERTZERO_WORKER_DARK_CONTINUOUS_THREAT_HUNT_WORKFLOW_ID, diff --git a/x-pack/platform/packages/shared/agent-builder/agent-builder-server/allow_lists.ts b/x-pack/platform/packages/shared/agent-builder/agent-builder-server/allow_lists.ts index bf5cd793745dc..b179f5df477f7 100644 --- a/x-pack/platform/packages/shared/agent-builder/agent-builder-server/allow_lists.ts +++ b/x-pack/platform/packages/shared/agent-builder/agent-builder-server/allow_lists.ts @@ -56,6 +56,7 @@ export const AGENT_BUILDER_BUILTIN_TOOLS = [ `${internalNamespaces.ml}.query_anomalies`, // Security Solution + `${internalNamespaces.security}.alertzero.actions.list`, `${internalNamespaces.security}.entity_risk_score`, `${internalNamespaces.security}.create_detection_rule`, `${internalNamespaces.security}.run_rule_preview`, diff --git a/x-pack/solutions/security/packages/kbn-alertzero-common/action_catalog_types.ts b/x-pack/solutions/security/packages/kbn-alertzero-common/action_catalog_types.ts new file mode 100644 index 0000000000000..83581c090ec59 --- /dev/null +++ b/x-pack/solutions/security/packages/kbn-alertzero-common/action_catalog_types.ts @@ -0,0 +1,31 @@ +/* + * 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 { ActionApprovalPolicy, ActionCategory, ActionImpact } from '@kbn/workflows'; + +export type { ActionApprovalPolicy, ActionCategory, ActionImpact }; + +/** + * One entry of the action catalog: the lightweight, agent-facing projection of + * an installed action workflow. Mirrors `consts.actionMetadata` on the + * workflow definition plus the workflow id, so an agent can propose the action + * without reading the full YAML. + */ +export interface ActionCatalogEntry { + workflowId: string; + name: string; + description?: string; + category?: ActionCategory; + impact?: ActionImpact; + approvalPolicy?: ActionApprovalPolicy; +} + +/** Response of `GET /internal/alertzero/actions`. */ +export interface ListActionsResponse { + actions: ActionCatalogEntry[]; + total: number; +} diff --git a/x-pack/solutions/security/packages/kbn-alertzero-common/constants.ts b/x-pack/solutions/security/packages/kbn-alertzero-common/constants.ts index b0e4babcedb71..9dafdd1797e41 100644 --- a/x-pack/solutions/security/packages/kbn-alertzero-common/constants.ts +++ b/x-pack/solutions/security/packages/kbn-alertzero-common/constants.ts @@ -44,6 +44,12 @@ export const buildInvestigationUrl = (id: string) => /** Proposals grouped by category — AlertZero landing page. */ export const ALERTZERO_PROPOSALS_URL = `${ALERTZERO_INTERNAL_URL}/proposals` as const; +/** Action catalog — category-scoped discovery of installed action workflows. */ +export const ALERTZERO_ACTIONS_URL = `${ALERTZERO_INTERNAL_URL}/actions` as const; + +/** Agent Builder builtin tool wrapping the action catalog API. */ +export const ALERTZERO_ACTIONS_LIST_TOOL_ID = 'security.alertzero.actions.list' as const; + /** * Shared thin AlertZero agent for all Worker `ai.agent` steps. * Can expand this to multiple scoped thin agents in the future if needed. diff --git a/x-pack/solutions/security/packages/kbn-alertzero-common/index.ts b/x-pack/solutions/security/packages/kbn-alertzero-common/index.ts index 4a460499e7c87..5b2c25741990f 100644 --- a/x-pack/solutions/security/packages/kbn-alertzero-common/index.ts +++ b/x-pack/solutions/security/packages/kbn-alertzero-common/index.ts @@ -24,6 +24,8 @@ export { ALERTZERO_INVESTIGATIONS_URL, ALERTZERO_INVESTIGATION_URL_TEMPLATE, ALERTZERO_PLUGIN_NAME, + ALERTZERO_ACTIONS_URL, + ALERTZERO_ACTIONS_LIST_TOOL_ID, ALERTZERO_PROPOSALS_URL, ALERTZERO_THIN_AGENT_ID, ALERTZERO_SKILLS_URL, @@ -64,6 +66,13 @@ export { buildWorkerUrl, } from './constants'; +export type { + ActionApprovalPolicy, + ActionCategory, + ActionCatalogEntry, + ActionImpact, + ListActionsResponse, +} from './action_catalog_types'; export { CONVERSATION_QUEUE_CATEGORIES, CONVERSATION_QUEUE_LABELS } from './translations'; export { diff --git a/x-pack/solutions/security/packages/kbn-alertzero-common/moon.yml b/x-pack/solutions/security/packages/kbn-alertzero-common/moon.yml index 51fa088950842..f62b9f1250e50 100644 --- a/x-pack/solutions/security/packages/kbn-alertzero-common/moon.yml +++ b/x-pack/solutions/security/packages/kbn-alertzero-common/moon.yml @@ -20,6 +20,7 @@ dependsOn: - '@kbn/zod' - '@kbn/i18n' - '@kbn/deeplinks-security' + - '@kbn/workflows' tags: - shared-common - package diff --git a/x-pack/solutions/security/packages/kbn-alertzero-common/tsconfig.json b/x-pack/solutions/security/packages/kbn-alertzero-common/tsconfig.json index d1ee06880a4ce..e205a3053175d 100644 --- a/x-pack/solutions/security/packages/kbn-alertzero-common/tsconfig.json +++ b/x-pack/solutions/security/packages/kbn-alertzero-common/tsconfig.json @@ -18,5 +18,6 @@ "@kbn/zod", "@kbn/i18n", "@kbn/deeplinks-security", + "@kbn/workflows", ] } diff --git a/x-pack/solutions/security/plugins/alertzero/moon.yml b/x-pack/solutions/security/plugins/alertzero/moon.yml index 9c0a46dcb06bb..fa66cfe9181e7 100644 --- a/x-pack/solutions/security/plugins/alertzero/moon.yml +++ b/x-pack/solutions/security/plugins/alertzero/moon.yml @@ -48,6 +48,8 @@ dependsOn: - '@kbn/core-http-server-mocks' - '@kbn/std' - '@kbn/core-security-server' + - '@kbn/core-logging-server-mocks' + - '@kbn/core-http-server' - '@kbn/charts-theme' tags: - plugin diff --git a/x-pack/solutions/security/plugins/alertzero/server/agent_builder_tools/list_actions_tool.test.ts b/x-pack/solutions/security/plugins/alertzero/server/agent_builder_tools/list_actions_tool.test.ts new file mode 100644 index 0000000000000..851dac7c04ac2 --- /dev/null +++ b/x-pack/solutions/security/plugins/alertzero/server/agent_builder_tools/list_actions_tool.test.ts @@ -0,0 +1,74 @@ +/* + * 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 { listActionsTool } from './list_actions_tool'; +import type { ActionsService } from '../services/actions/actions_service'; +import { ToolResultType } from '@kbn/agent-builder-common/tools/tool_result'; + +const logger = () => ({ error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() }); + +const serviceWith = (list: jest.Mock) => ({ list } as Pick); + +const run = async ( + service: Pick, + input: { categories?: string[] } = {} +) => { + const tool = listActionsTool(() => service); + const result = await tool.handler(input, { logger: logger() } as never); + if (!('results' in result)) { + throw new Error('expected a standard tool result'); + } + return result; +}; + +const ACTION = (over: Partial> = {}) => ({ + workflowId: 'system-alertzero-action-create-rule', + name: 'Create detection rule', + category: 'tune', + ...over, +}); + +describe('listActionsTool', () => { + it('lists all actions when called without categories', async () => { + const list = jest.fn().mockResolvedValue({ + actions: [ACTION(), ACTION({ workflowId: 'a2', name: 'Isolate host', category: 'contain' })], + total: 2, + }); + const result = await run(serviceWith(list)); + expect(list).toHaveBeenCalledWith('default', undefined); + expect(result.results[0].type).toBe(ToolResultType.other); + expect(result.results[0].data).toMatchObject({ total: 2 }); + }); + + it('forwards categories to the service and reports empty results explicitly', async () => { + const list = jest.fn().mockResolvedValue({ actions: [], total: 0 }); + const result = await run(serviceWith(list), { categories: ['escalate'] }); + expect(list).toHaveBeenCalledWith('default', ['escalate']); + expect(result.results[0].data).toMatchObject({ + total: 0, + message: 'No actions found in categories: escalate.', + }); + }); + + it('returns an error result instead of throwing when the service fails', async () => { + const list = jest.fn().mockRejectedValue(new Error('workflows management down')); + const result = await run(serviceWith(list)); + expect(result.results[0].type).not.toBe(ToolResultType.other); + expect(JSON.stringify(result.results[0])).toContain('workflows management down'); + }); + + it('declares the documented tool id and read-only annotations', () => { + const tool = listActionsTool(() => serviceWith(jest.fn())); + expect(tool.id).toBe('security.alertzero.actions.list'); + expect(tool.annotations).toMatchObject({ + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }); + expect(tool.type).toBe('builtin'); + }); +}); diff --git a/x-pack/solutions/security/plugins/alertzero/server/agent_builder_tools/list_actions_tool.ts b/x-pack/solutions/security/plugins/alertzero/server/agent_builder_tools/list_actions_tool.ts new file mode 100644 index 0000000000000..2107b0ed4a1a0 --- /dev/null +++ b/x-pack/solutions/security/plugins/alertzero/server/agent_builder_tools/list_actions_tool.ts @@ -0,0 +1,80 @@ +/* + * 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 { z } from '@kbn/zod/v4'; +import { createErrorResult } from '@kbn/agent-builder-server'; +import { ToolResultType } from '@kbn/agent-builder-common/tools/tool_result'; +import type { BuiltinToolDefinition } from '@kbn/agent-builder-server/tools'; +import { ToolType } from '@kbn/agent-builder-common'; +import { actionCategorySchema } from '@kbn/workflows/managed'; +import { ALERTZERO_ACTIONS_LIST_TOOL_ID } from '@kbn/alertzero-common'; +import type { ActionsService } from '../services/actions/actions_service'; + +const listByCategorySchema = z.object({ + categories: z + .array(actionCategorySchema) + .max(20) + .optional() + .describe( + 'Category keywords to filter on (e.g. ["contain", "escalate"]). An action is returned when its declared category matches ANY of these. Omit to list every available action.' + ), +}); + +/** + * `security.alertzero.actions.list` — lets an agent discover the + * installed action workflows at runtime instead of hard-coding workflow ids. + * + * Registered by the AlertZero plugin (setup), reads the catalog through + * {@link ActionsService} — the same service backing the HTTP API — so the tool + * and the API can never drift. + */ +export const listActionsTool = ( + getActionsService: () => Pick +): BuiltinToolDefinition => ({ + id: ALERTZERO_ACTIONS_LIST_TOOL_ID, + type: ToolType.builtin, + description: + 'List available AlertZero actions, optionally filtered by category. Each result includes the workflowId to reference when proposing the action, plus its name, description, category, impact (low/medium/high/critical) and approvalPolicy (always-gate/autonomy-dependent). Call this before proposing an action so the proposal references a real, installed workflow.', + annotations: { + title: 'List AlertZero Actions', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + schema: listByCategorySchema, + tags: ['alertzero'], + handler: async ({ categories }, { logger }) => { + try { + const { actions, total } = await getActionsService().list('default', categories); + const message = + total === 0 + ? categories + ? `No actions found in categories: ${categories.join(', ')}.` + : 'No actions are installed.' + : undefined; + return { + results: [ + { + type: ToolResultType.other, + data: { + total, + actions, + ...(message && { message }), + }, + }, + ], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error(`[List Actions Tool] Error listing actions: ${errorMessage}`); + return { + results: [createErrorResult(`Error listing actions: ${errorMessage}`)], + }; + } + }, +}); diff --git a/x-pack/solutions/security/plugins/alertzero/server/plugin.test.ts b/x-pack/solutions/security/plugins/alertzero/server/plugin.test.ts index 11936cc4c5b38..58c987d435ee2 100644 --- a/x-pack/solutions/security/plugins/alertzero/server/plugin.test.ts +++ b/x-pack/solutions/security/plugins/alertzero/server/plugin.test.ts @@ -103,6 +103,7 @@ describe('AlertZeroPlugin feature-flag gating', () => { features, workflowsExtensions, workflowsManagement: { management: {} }, + agentBuilder: { tools: { register: jest.fn() } }, } as never ); @@ -130,7 +131,7 @@ describe('AlertZeroPlugin feature-flag gating', () => { const coreSetup = coreMock.createSetup(); const features = { registerKibanaFeature: jest.fn() }; const workflowsExtensions = { registerManagedWorkflowOwner: jest.fn() }; - const agentBuilder = { agents: { registerType: jest.fn() } }; + const agentBuilder = { agents: { registerType: jest.fn() }, tools: { register: jest.fn() } }; plugin.setup( coreSetup as never, diff --git a/x-pack/solutions/security/plugins/alertzero/server/plugin.ts b/x-pack/solutions/security/plugins/alertzero/server/plugin.ts index a9ebb625d6f12..0f572573e7aea 100644 --- a/x-pack/solutions/security/plugins/alertzero/server/plugin.ts +++ b/x-pack/solutions/security/plugins/alertzero/server/plugin.ts @@ -36,6 +36,8 @@ import { WatchesService } from './services/watches/watches_service'; import { WorkersService } from './services/workers/workers_service'; import { ConversationProposalsService } from './services/conversation_proposals/conversation_proposals_service'; import { WatchWorkflowsManagementClientImpl } from './services/watches/watch_workflows_management_client'; +import { ActionsService } from './services/actions/actions_service'; +import { listActionsTool } from './agent_builder_tools/list_actions_tool'; import { agentType, ensureAgent, ensureAgentSafe, registerAgentType } from './agent'; export class AlertZeroPlugin @@ -54,6 +56,7 @@ export class AlertZeroPlugin /** Created during `start`; routes resolve them lazily after managed-workflow initialization. */ private watchesService?: WatchesService; + private actionsService?: ActionsService; private workersService?: WorkersService; private conversationProposalsService?: ConversationProposalsService; @@ -83,6 +86,11 @@ export class AlertZeroPlugin registerOwner({ workflowsExtensions }); registerAgentType(agentBuilder); + // Registered in setup so the builtin tool is available to Agent Builder before + // the first agent run; the handler resolves the service lazily like the routes do. + agentBuilder.tools.register({ + ...listActionsTool(() => this.requireActionsService()), + }); features.registerKibanaFeature({ id: ALERTZERO_FEATURE_ID, @@ -116,6 +124,7 @@ export class AlertZeroPlugin getWatchesService: () => this.requireWatchesService(), getWorkersService: () => this.requireWorkersService(), getConversationProposalsService: () => this.requireConversationProposalsService(), + getActionsService: () => this.requireActionsService(), }); return {}; @@ -160,6 +169,13 @@ export class AlertZeroPlugin // Mock mode changes presentation data only; durable Worker settings and enablement still use Workflows. this.watchesService = new WatchesService(); + this.actionsService = new ActionsService( + () => + this.workflowsManagementApi + ? new WatchWorkflowsManagementClientImpl(this.workflowsManagementApi) + : undefined, + this.logger + ); this.workersService = new WorkersService(management, managedWorkflows, this.logger, { ensureAgentForSpace: plugins.agentBuilder ? (spaceId) => @@ -179,6 +195,12 @@ export class AlertZeroPlugin return this.watchesService; } + private requireActionsService(): ActionsService { + if (!this.actionsService) { + throw new Error('Actions service is not available until the AlertZero plugin has started'); + } + return this.actionsService; + } private requireWorkersService(): WorkersService { if (!this.workersService) { throw new Error('Workers service is not available until the AlertZero plugin has started'); diff --git a/x-pack/solutions/security/plugins/alertzero/server/routes/actions/constants.ts b/x-pack/solutions/security/plugins/alertzero/server/routes/actions/constants.ts new file mode 100644 index 0000000000000..bb18d373ceea4 --- /dev/null +++ b/x-pack/solutions/security/plugins/alertzero/server/routes/actions/constants.ts @@ -0,0 +1,9 @@ +/* + * 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. + */ + +/** Max number of values accepted in the `categories` query param (each also length-capped upstream by actionCategorySchema). */ +export const ACTION_CATEGORIES_QUERY_PARAM_MAX_ITEMS = 20; diff --git a/x-pack/solutions/security/plugins/alertzero/server/routes/actions/list_actions.test.ts b/x-pack/solutions/security/plugins/alertzero/server/routes/actions/list_actions.test.ts new file mode 100644 index 0000000000000..f4e68b355f78e --- /dev/null +++ b/x-pack/solutions/security/plugins/alertzero/server/routes/actions/list_actions.test.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 { httpServerMock } from '@kbn/core-http-server-mocks'; +import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; +import type { RouteDependencies } from '../register_routes'; +import { registerListActionsRoute } from './list_actions'; + +const makeDeps = (actionsService: unknown) => { + const addVersion = jest.fn(); + const router = { + versioned: { + get: jest.fn().mockReturnValue({ addVersion }), + }, + }; + registerListActionsRoute({ + router: router as unknown as RouteDependencies['router'], + logger: loggingSystemMock.createLogger(), + getSpaceId: () => 'default', + getActionsService: () => actionsService, + } as unknown as RouteDependencies); + const handler = addVersion.mock.calls[0][1] as ( + context: unknown, + request: ReturnType, + response: ReturnType + ) => Promise; + return { handler }; +}; + +const requestWithCategories = (categories?: string[]) => + httpServerMock.createKibanaRequest({ + path: '/internal/alertzero/actions', + query: categories ? { categories } : undefined, + }); + +describe('registerListActionsRoute', () => { + it('passes categories through to the service', async () => { + const list = jest.fn().mockResolvedValue({ actions: [], total: 0 }); + const { handler } = makeDeps({ list }); + const response = httpServerMock.createResponseFactory(); + await handler( + {}, + httpServerMock.createKibanaRequest({ + path: '/internal/alertzero/actions', + // simulate the router-parsed multi-valued query param + query: { categories: ['contain', 'escalate'] }, + }), + response + ); + expect(list).toHaveBeenCalledWith('default', ['contain', 'escalate']); + expect(response.ok).toHaveBeenCalled(); + }); + + it('returns the full catalog when no categories are given', async () => { + const list = jest.fn().mockResolvedValue({ actions: [], total: 0 }); + const { handler } = makeDeps({ list }); + const response = httpServerMock.createResponseFactory(); + await handler({}, requestWithCategories(), response); + expect(list).toHaveBeenCalledWith('default', undefined); + }); + + it('maps an invalid categories param to 400 with the param message', async () => { + const { handler } = makeDeps({ list: jest.fn() }); + const response = httpServerMock.createResponseFactory(); + const request = httpServerMock.createKibanaRequest({ + path: '/internal/alertzero/actions', + query: { categories: Array(21).fill('c') }, + }); + await handler({}, request, response); + expect(response.badRequest).toHaveBeenCalledWith({ + body: { + message: expect.stringContaining('at most 20'), + }, + }); + }); + + it('maps service errors to 500', async () => { + const list = jest.fn().mockRejectedValue(new Error('boom')); + const { handler } = makeDeps({ list }); + const response = httpServerMock.createResponseFactory(); + await handler({}, requestWithCategories(), response); + expect(response.customError).toHaveBeenCalledWith({ + statusCode: 500, + body: { message: 'Failed to list actions' }, + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/alertzero/server/routes/actions/list_actions.ts b/x-pack/solutions/security/plugins/alertzero/server/routes/actions/list_actions.ts new file mode 100644 index 0000000000000..adc746fa3c4ca --- /dev/null +++ b/x-pack/solutions/security/plugins/alertzero/server/routes/actions/list_actions.ts @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { API_VERSIONS, INTERNAL_API_ACCESS, ALERTZERO_ACTIONS_URL } from '@kbn/alertzero-common'; +import type { ListActionsResponse } from '@kbn/alertzero-common'; +import { ALERTZERO_API_PRIVILEGE_READ } from '../../../common/constants'; +import type { RouteDependencies } from '../register_routes'; +import { + InvalidCategoriesError, + readActionCategoriesQueryParam, +} from './read_categories_query_param'; + +export const registerListActionsRoute = ({ + router, + logger, + getSpaceId, + getActionsService, +}: RouteDependencies) => { + router.versioned + .get({ + path: ALERTZERO_ACTIONS_URL, + access: INTERNAL_API_ACCESS, + security: { + authz: { + requiredPrivileges: [ALERTZERO_API_PRIVILEGE_READ], + }, + }, + summary: 'List AlertZero action workflows, optionally filtered by category', + }) + .addVersion( + { + version: API_VERSIONS.internal.v1, + validate: { + request: {}, + }, + }, + async (_context, request, response) => { + try { + const categories = readActionCategoriesQueryParam(request); + const body: ListActionsResponse = await getActionsService().list( + getSpaceId(request), + categories + ); + return response.ok({ body }); + } catch (error) { + if (error instanceof InvalidCategoriesError) { + return response.badRequest({ body: { message: error.message } }); + } + logger.error(`Failed to list actions: ${error}`); + return response.customError({ + statusCode: 500, + body: { message: 'Failed to list actions' }, + }); + } + } + ); +}; diff --git a/x-pack/solutions/security/plugins/alertzero/server/routes/actions/read_categories_query_param.test.ts b/x-pack/solutions/security/plugins/alertzero/server/routes/actions/read_categories_query_param.test.ts new file mode 100644 index 0000000000000..586639d313587 --- /dev/null +++ b/x-pack/solutions/security/plugins/alertzero/server/routes/actions/read_categories_query_param.test.ts @@ -0,0 +1,66 @@ +/* + * 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 { httpServerMock } from '@kbn/core-http-server-mocks'; +import { readActionCategoriesQueryParam } from './read_categories_query_param'; +import { ACTION_CATEGORIES_QUERY_PARAM_MAX_ITEMS } from './constants'; + +const requestWithQuery = (query: Record | undefined) => + httpServerMock.createKibanaRequest({ path: '/internal/alertzero/actions', query }); + +describe('readActionCategoriesQueryParam', () => { + it('returns undefined when the param is absent', () => { + expect(readActionCategoriesQueryParam(requestWithQuery(undefined))).toBeUndefined(); + }); + + it('returns undefined for empty / blank values', () => { + expect(readActionCategoriesQueryParam(requestWithQuery({ categories: '' }))).toBeUndefined(); + expect(readActionCategoriesQueryParam(requestWithQuery({ categories: ' ' }))).toBeUndefined(); + }); + + it('returns the single category as a one-element array', () => { + expect(readActionCategoriesQueryParam(requestWithQuery({ categories: 'contain' }))).toEqual([ + 'contain', + ]); + }); + + it('returns the multi-valued param as an OR-set', () => { + expect( + readActionCategoriesQueryParam(requestWithQuery({ categories: ['contain', 'escalate'] })) + ).toEqual(['contain', 'escalate']); + }); + + it('splits a comma-joined single value', () => { + expect( + readActionCategoriesQueryParam(requestWithQuery({ categories: 'contain,tune' })) + ).toEqual(['contain', 'tune']); + }); + + it('rejects more than the max values even when comma-joined', () => { + expect(() => + readActionCategoriesQueryParam( + requestWithQuery({ categories: Array(21).fill('c').join(',') }) + ) + ).toThrow(/at most 20/); + }); + + it('trims whitespace around values', () => { + expect(readActionCategoriesQueryParam(requestWithQuery({ categories: ' contain ' }))).toEqual([ + 'contain', + ]); + }); + + it('rejects more than the max values', () => { + const tooMany = Array.from( + { length: ACTION_CATEGORIES_QUERY_PARAM_MAX_ITEMS + 1 }, + (_, i) => `c${i}` + ); + expect(() => readActionCategoriesQueryParam(requestWithQuery({ categories: tooMany }))).toThrow( + /at most \d+ values/ + ); + }); +}); diff --git a/x-pack/solutions/security/plugins/alertzero/server/routes/actions/read_categories_query_param.ts b/x-pack/solutions/security/plugins/alertzero/server/routes/actions/read_categories_query_param.ts new file mode 100644 index 0000000000000..dac473f4642e8 --- /dev/null +++ b/x-pack/solutions/security/plugins/alertzero/server/routes/actions/read_categories_query_param.ts @@ -0,0 +1,39 @@ +/* + * 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 } from '@kbn/core-http-server'; +import { ACTION_CATEGORIES_QUERY_PARAM_MAX_ITEMS } from './constants'; + +/** + * Reads the optional `categories` query param from the list-actions request. + * + * `categories` is a multi-valued query param (`?categories=contain&categories=escalate`); + * a comma-joined single value (`?categories=contain,escalate`) is accepted and split. + * The returned array is the OR-set the API filters on: an action is returned + * when its declared category matches ANY of the requested categories. + * Absent / empty param → `undefined`, meaning "no category filter, return all". + * + * The category vocabulary is solution-owned (alertzero, nightshift, …), so + * nothing is validated against a fixed enum here — unknown categories simply + * match nothing. + */ +/** Thrown when the `categories` query param is structurally invalid (too many values). */ +export class InvalidCategoriesError extends Error {} + +export const readActionCategoriesQueryParam = (request: KibanaRequest): string[] | undefined => { + const raw = request.url.searchParams.getAll('categories').flatMap((value) => value.split(',')); + const trimmed = raw.map((value) => value.trim()).filter((value) => value.length > 0); + if (trimmed.length === 0) { + return undefined; + } + if (trimmed.length > ACTION_CATEGORIES_QUERY_PARAM_MAX_ITEMS) { + throw new InvalidCategoriesError( + `The categories query param accepts at most ${ACTION_CATEGORIES_QUERY_PARAM_MAX_ITEMS} values` + ); + } + return trimmed; +}; diff --git a/x-pack/solutions/security/plugins/alertzero/server/routes/register_routes.ts b/x-pack/solutions/security/plugins/alertzero/server/routes/register_routes.ts index b3643efed2c72..4b7aee3ba1246 100644 --- a/x-pack/solutions/security/plugins/alertzero/server/routes/register_routes.ts +++ b/x-pack/solutions/security/plugins/alertzero/server/routes/register_routes.ts @@ -11,6 +11,7 @@ import type { AlertZeroSpaceIdResolver } from '../types'; import type { WatchesService } from '../services/watches/watches_service'; import type { WorkersService } from '../services/workers/workers_service'; import type { ConversationProposalsService } from '../services/conversation_proposals/conversation_proposals_service'; +import type { ActionsService } from '../services/actions/actions_service'; import { registerListWatchesRoute } from './watches/list_watches'; import { registerGetWatchRoute } from './watches/get_watch'; import { registerListWorkersRoute } from './workers/list_workers'; @@ -19,6 +20,7 @@ import { registerListInvestigationsRoute } from './investigations/list_investiga import { registerGetInvestigationRoute } from './investigations/get_investigation'; import { registerListInvestigationProposalsRoute } from './investigations/list_proposals'; import { registerGetProposalsRoute } from './proposals/get_proposals'; +import { registerListActionsRoute } from './actions/list_actions'; export interface RouteDependencies { router: IRouter; @@ -28,6 +30,7 @@ export interface RouteDependencies { getWatchesService: () => WatchesService; getWorkersService: () => WorkersService; getConversationProposalsService: () => ConversationProposalsService; + getActionsService: () => ActionsService; } export const registerRoutes = (deps: RouteDependencies): void => { @@ -39,4 +42,5 @@ export const registerRoutes = (deps: RouteDependencies): void => { registerGetInvestigationRoute(deps); registerListInvestigationProposalsRoute(deps); registerGetProposalsRoute(deps); + registerListActionsRoute(deps); }; diff --git a/x-pack/solutions/security/plugins/alertzero/server/services/actions/actions_service.test.ts b/x-pack/solutions/security/plugins/alertzero/server/services/actions/actions_service.test.ts new file mode 100644 index 0000000000000..0563f615b9fb5 --- /dev/null +++ b/x-pack/solutions/security/plugins/alertzero/server/services/actions/actions_service.test.ts @@ -0,0 +1,184 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; +import type { WorkflowListDto, WorkflowListItemDto } from '@kbn/workflows'; +import { ActionsService } from './actions_service'; +import type { WatchWorkflowsManagementClient } from '../watches/watch_workflows_management_client'; + +const logger = loggingSystemMock.create().get('alertzero'); + +const makeManagement = ( + pages: WorkflowListDto[] +): { client: WatchWorkflowsManagementClient; getWorkflows: jest.Mock } => { + const getWorkflows = jest.fn(); + pages.forEach((page, index) => { + getWorkflows.mockResolvedValueOnce(page); + // Any further call falls through with the last page (defensive: service must stop paging). + if (index === pages.length - 1) { + getWorkflows.mockResolvedValue(page); + } + }); + return { + getWorkflows, + client: { getWorkflows } as unknown as WatchWorkflowsManagementClient, + }; +}; + +const workflowItem = (id: string, actionMetadata: unknown, tags: string[] = ['action']) => ({ + id, + name: id, + description: '', + enabled: true, + managed: true, + managedBy: 'alertzero', + definition: (actionMetadata === null + ? null + : { consts: { actionMetadata } }) as WorkflowListItemDto['definition'], + createdAt: '2026-01-01T00:00:00.000Z', + tags, + valid: true, +}); + +const page = (results: WorkflowListDto['results'], total = results.length): WorkflowListDto => ({ + page: 1, + size: 100, + total, + results, +}); + +describe('ActionsService', () => { + it('queries managed workflows tagged action (managed installs, not unmanaged)', async () => { + const { getWorkflows, client } = makeManagement([page([])]); + const service = new ActionsService(() => client, logger); + await service.list('default'); + expect(getWorkflows).toHaveBeenCalledWith( + expect.objectContaining({ tags: ['action'], managedFilter: 'managed' }), + 'default' + ); + }); + + it('projects action workflows to catalog entries', async () => { + const { client } = makeManagement([ + page([ + workflowItem('action-create-rule', { + name: 'Create detection rule', + description: 'Creates a new, disabled custom query detection rule.', + category: 'tune', + impact: 'low', + reversible: true, + approvalPolicy: 'always-gate', + }), + ]), + ]); + const service = new ActionsService(() => client, logger); + const result = await service.list('default'); + expect(result).toEqual({ + total: 1, + actions: [ + { + workflowId: 'action-create-rule', + name: 'Create detection rule', + description: 'Creates a new, disabled custom query detection rule.', + category: 'tune', + impact: 'low', + approvalPolicy: 'always-gate', + }, + ], + }); + }); + + it('filters by category (OR semantics) and omits entries without a category', async () => { + const { client, getWorkflows } = makeManagement([ + page([ + workflowItem('a-contain', { name: 'A', category: 'contain' }), + workflowItem('b-escalate', { name: 'B', category: 'escalate' }), + workflowItem('c-tune', { name: 'C', category: 'tune' }), + workflowItem('d-uncategorized', { name: 'D' }), + ]), + ]); + const service = new ActionsService(() => client, logger); + const result = await service.list('default', ['contain', 'escalate']); + expect(result.actions.map((a) => a.workflowId)).toEqual(['a-contain', 'b-escalate']); + expect(result.total).toBe(2); + // the filter is applied AFTER fetching, so the API still queries by tag only + expect(getWorkflows).toHaveBeenCalledWith( + expect.objectContaining({ tags: ['action'] }), + 'default' + ); + }); + + it('does not validate categories against an enum — unknown categories just match nothing', async () => { + const { client } = makeManagement([ + page([workflowItem('a-contain', { name: 'A', category: 'contain' })]), + ]); + const service = new ActionsService(() => client, logger); + const result = await service.list('default', ['nightshift-specific-category']); + expect(result).toEqual({ actions: [], total: 0 }); + }); + + it('skips workflows with invalid actionMetadata, keeping the rest of the catalog', async () => { + const { client } = makeManagement([ + page([ + workflowItem('invalid', { name: 'x', impact: 'nuclear' }), + workflowItem('valid', { name: 'Valid', category: 'contain' }), + ]), + ]); + const service = new ActionsService(() => client, logger); + const result = await service.list('default'); + expect(result.actions.map((a) => a.workflowId)).toEqual(['valid']); + }); + + it('skips workflows without actionMetadata', async () => { + const { client } = makeManagement([ + page([ + workflowItem('no-metadata', undefined), + workflowItem('null-definition', null), + workflowItem('valid', { name: 'Valid' }), + ]), + ]); + const service = new ActionsService(() => client, logger); + const result = await service.list('default'); + expect(result.actions.map((a) => a.workflowId)).toEqual(['valid']); + }); + + it('pages until all tagged workflows are read', async () => { + const first = Array.from({ length: 100 }, (_, i) => + workflowItem(`wf-${i}`, { name: `wf-${i}` }) + ); + const second = [workflowItem('wf-100', { name: 'wf-100' })]; + const { client, getWorkflows } = makeManagement([ + { ...page(first, 101), page: 1 }, + { ...page(second, 101), page: 2 }, + ]); + const service = new ActionsService(() => client, logger); + const result = await service.list('default'); + expect(result.total).toBe(101); + expect(getWorkflows).toHaveBeenCalledTimes(2); + expect(getWorkflows).toHaveBeenLastCalledWith( + expect.objectContaining({ page: 2, size: 100 }), + 'default' + ); + }); + + it('throws when workflows management is unavailable', async () => { + const service = new ActionsService(() => undefined, logger); + await expect(service.list('default')).rejects.toThrow('Workflows management is not available'); + }); + + it('sorts entries by name', async () => { + const { client } = makeManagement([ + page([ + workflowItem('zeta', { name: 'Zeta action' }), + workflowItem('alpha', { name: 'Alpha action' }), + ]), + ]); + const service = new ActionsService(() => client, logger); + const result = await service.list('default'); + expect(result.actions.map((a) => a.name)).toEqual(['Alpha action', 'Zeta action']); + }); +}); diff --git a/x-pack/solutions/security/plugins/alertzero/server/services/actions/actions_service.ts b/x-pack/solutions/security/plugins/alertzero/server/services/actions/actions_service.ts new file mode 100644 index 0000000000000..19497556200d2 --- /dev/null +++ b/x-pack/solutions/security/plugins/alertzero/server/services/actions/actions_service.ts @@ -0,0 +1,110 @@ +/* + * 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 { ACTION_WORKFLOW_TAG, actionMetadataSchema } from '@kbn/workflows/managed'; +import type { WorkflowListDto } from '@kbn/workflows'; +import type { Logger } from '@kbn/logging'; +import type { ActionCatalogEntry, ListActionsResponse } from '@kbn/alertzero-common'; +import type { WatchWorkflowsManagementClient } from '../watches/watch_workflows_management_client'; + +/** Structural subset of WorkflowListItemDto.definition the catalog reads. */ +interface ActionWorkflowDefinition { + consts?: { actionMetadata?: unknown }; +} + +const PAGE_SIZE = 100; + +/** + * Action catalog service: lists installed action workflows and projects them to + * the lightweight {@link ActionCatalogEntry} shape. + * + * Discovery is tag-driven (`action`), never a hardcoded workflow-id list, and + * category is a solution-owned keyword — never validated against an enum. + * Definitions declaring invalid `consts.actionMetadata` are skipped with a + * warning rather than failing the whole catalog. + */ +export class ActionsService { + constructor( + private readonly getManagement: () => WatchWorkflowsManagementClient | undefined, + private readonly logger: Logger + ) {} + + async list(spaceId: string, categories?: string[]): Promise { + const management = this.getManagement(); + if (!management) { + throw new Error('Workflows management is not available'); + } + + const filter = categories && categories.length > 0 ? new Set(categories) : undefined; + + const actions: ActionCatalogEntry[] = []; + let page = 1; + // Page until exhausted. The catalog is small (installed action workflows), + // so the loop is bounded by the number of action workflows, not all workflows: + // the tag filter runs server-side inside getWorkflows. + for (;;) { + const response: WorkflowListDto = await management.getWorkflows( + { + tags: [ACTION_WORKFLOW_TAG], + page, + size: PAGE_SIZE, + // Action workflows are managed installs (global space); the search + // service's `unmanaged` default would filter them all out. + managedFilter: 'managed', + }, + spaceId + ); + for (const item of response.results) { + const entry = this.toEntry(item.id, item.definition as ActionWorkflowDefinition | null); + if (!entry) { + continue; + } + if (filter && (entry.category === undefined || !filter.has(entry.category))) { + continue; + } + actions.push(entry); + } + if (page * PAGE_SIZE >= response.total) { + break; + } + page += 1; + } + + actions.sort((a, b) => a.name.localeCompare(b.name)); + return { actions, total: actions.length }; + } + + /** + * Projects a workflow definition to a catalog entry, or `undefined` when the + * definition carries no parseable `consts.actionMetadata`. + */ + private toEntry( + workflowId: string, + definition: ActionWorkflowDefinition | null + ): ActionCatalogEntry | undefined { + const candidate = definition?.consts?.actionMetadata; + if (candidate === undefined) { + return undefined; + } + const parsed = actionMetadataSchema.safeParse(candidate); + if (!parsed.success) { + this.logger.warn( + `Action workflow [${workflowId}] declares invalid consts.actionMetadata: ${parsed.error.message}` + ); + return undefined; + } + const { name, description, category, impact, approvalPolicy } = parsed.data; + return { + workflowId, + name, + ...(description !== undefined && { description }), + ...(category !== undefined && { category }), + ...(impact !== undefined && { impact }), + ...(approvalPolicy !== undefined && { approvalPolicy }), + }; + } +} diff --git a/x-pack/solutions/security/plugins/alertzero/tsconfig.json b/x-pack/solutions/security/plugins/alertzero/tsconfig.json index 6847033ff086b..d1fe813378810 100644 --- a/x-pack/solutions/security/plugins/alertzero/tsconfig.json +++ b/x-pack/solutions/security/plugins/alertzero/tsconfig.json @@ -37,6 +37,8 @@ "@kbn/core-http-server-mocks", "@kbn/std", "@kbn/core-security-server", + "@kbn/core-logging-server-mocks", + "@kbn/core-http-server", "@kbn/charts-theme", ] }