Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ import { defaultDeepLinks } from './app/links/default_deep_links';
import { AIValueReportLocatorDefinition } from '../common/locators/ai_value_report/locator';
import { registerAttachmentUiDefinitions } from './agent_builder/attachment_types';
import { registerRuleAttachment } from './agent_builder/attachment_types/rule_attachment';
import { registerWorkflowSteps } from './workflows/step_types';

export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, StartPlugins> {
private config: SecuritySolutionUiConfigType;
Expand Down Expand Up @@ -127,20 +128,8 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
share.url.locators.create(new AIValueReportLocatorDefinition());
}

// Register workflow steps
if (workflowsExtensions) {
import('./workflows/step_types')
.then(async ({ registerWorkflowSteps }) => {
const [coreStart] = await core.getStartServices();
return registerWorkflowSteps(workflowsExtensions, coreStart);
})
.catch((error) => {
this.logger.error(
`Error registering security workflow steps: ${
error instanceof Error ? error.message : String(error)
}`
);
});
registerWorkflowSteps(workflowsExtensions, core);
}

// Lazily instantiate subPlugins and initialize services
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/*
* 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.
*/

module.exports = {
preset: '@kbn/test',
rootDir: '../../../../../../..',
roots: ['<rootDir>/x-pack/solutions/security/plugins/security_solution/public/workflows'],
coverageDirectory:
'<rootDir>/target/kibana-coverage/jest/x-pack/solutions/security/plugins/security_solution/public/workflows',
coverageReporters: ['text', 'html'],
collectCoverageFrom: [
'<rootDir>/x-pack/solutions/security/plugins/security_solution/public/workflows/**/*.{ts,tsx}',
],
moduleNameMapper: require('../../server/__mocks__/module_name_map'),
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import { coreMock } from '@kbn/core/public/mocks';
import type {
WorkflowsExtensionsPublicPluginSetup,
PublicStepDefinition,
} from '@kbn/workflows-extensions/public';
import { registerWorkflowSteps } from './register_workflow_steps';
import { renderAlertNarrativeStepDefinition } from './render_alert_narrative_step';
import { buildAlertEntityGraphStepDefinition } from './build_alert_entity_graph_step';
import {
REGISTER_ALERT_VALIDATION_STEPS_FEATURE_FLAG,
REGISTER_ALERT_VALIDATION_STEP_FEATURE_FLAG_DEFAULT,
} from '../../../common/constants';

type StepLoader = () => Promise<PublicStepDefinition | undefined>;

const createWorkflowsExtensionsMock = (): jest.Mocked<WorkflowsExtensionsPublicPluginSetup> => ({
registerStepDefinition: jest.fn(),
registerTriggerDefinition: jest.fn(),
});

describe('registerWorkflowSteps (public)', () => {
const buildCoreMock = (featureFlagEnabled: boolean) => {
const core = coreMock.createSetup();
const coreStart = coreMock.createStart();
coreStart.featureFlags.getBooleanValue.mockReturnValue(featureFlagEnabled);
core.getStartServices.mockResolvedValue([coreStart, {}, {}]);
return { core, coreStart };
};

it('calls registerStepDefinition synchronously for both steps', () => {
const { core } = buildCoreMock(true);
const workflowsExtensions = createWorkflowsExtensionsMock();

registerWorkflowSteps(workflowsExtensions, core);

expect(workflowsExtensions.registerStepDefinition).toHaveBeenCalledTimes(2);
// getStartServices is called once eagerly to create the shared memoized promise
expect(core.getStartServices).toHaveBeenCalledTimes(1);
});

it('async loader returns step definitions when feature flag is enabled', async () => {
const { core } = buildCoreMock(true);
const workflowsExtensions = createWorkflowsExtensionsMock();

registerWorkflowSteps(workflowsExtensions, core);

const [loader1, loader2] = workflowsExtensions.registerStepDefinition.mock.calls.map(
([arg]) => arg as StepLoader
);

await expect(loader1()).resolves.toBe(renderAlertNarrativeStepDefinition);
await expect(loader2()).resolves.toBe(buildAlertEntityGraphStepDefinition);
});

it('async loader returns undefined when feature flag is disabled', async () => {
const { core } = buildCoreMock(false);
const workflowsExtensions = createWorkflowsExtensionsMock();

registerWorkflowSteps(workflowsExtensions, core);

const [loader1, loader2] = workflowsExtensions.registerStepDefinition.mock.calls.map(
([arg]) => arg as StepLoader
);

await expect(loader1()).resolves.toBeUndefined();
await expect(loader2()).resolves.toBeUndefined();
});

it('checks the feature flag exactly once even when both loaders resolve', async () => {
const { core, coreStart } = buildCoreMock(true);
const workflowsExtensions = createWorkflowsExtensionsMock();

registerWorkflowSteps(workflowsExtensions, core);

const [loader1, loader2] = workflowsExtensions.registerStepDefinition.mock.calls.map(
([arg]) => arg as StepLoader
);
await Promise.all([loader1(), loader2()]);

expect(coreStart.featureFlags.getBooleanValue).toHaveBeenCalledTimes(1);
expect(coreStart.featureFlags.getBooleanValue).toHaveBeenCalledWith(
REGISTER_ALERT_VALIDATION_STEPS_FEATURE_FLAG,
REGISTER_ALERT_VALIDATION_STEP_FEATURE_FLAG_DEFAULT
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -6,28 +6,41 @@
*/

import type { WorkflowsExtensionsPublicPluginSetup } from '@kbn/workflows-extensions/public';
import type { CoreStart } from '@kbn/core/public';
import { renderAlertNarrativeStepDefinition } from './render_alert_narrative_step';
import { buildAlertEntityGraphStepDefinition } from './build_alert_entity_graph_step';
import type { CoreSetup } from '@kbn/core/public';
import {
REGISTER_ALERT_VALIDATION_STEPS_FEATURE_FLAG,
REGISTER_ALERT_VALIDATION_STEP_FEATURE_FLAG_DEFAULT,
} from '../../../common/constants';

/**
* Registers all security workflow steps with the workflowsExtensions plugin
* Registers all security workflow steps with the workflowsExtensions plugin.
* Registration is synchronous; each step uses an async loader to perform the
* feature-flag check at resolution time.
*/
export const registerWorkflowSteps = async (
export const registerWorkflowSteps = (
workflowsExtensions: WorkflowsExtensionsPublicPluginSetup,
core: CoreStart
): Promise<void> => {
const registerAlertValidationStepsEnabled = await core.featureFlags.getBooleanValue(
REGISTER_ALERT_VALIDATION_STEPS_FEATURE_FLAG,
REGISTER_ALERT_VALIDATION_STEP_FEATURE_FLAG_DEFAULT
);
core: CoreSetup
): void => {
const isEnabled = core
.getStartServices()
.then(([coreStart]) =>
coreStart.featureFlags.getBooleanValue(
REGISTER_ALERT_VALIDATION_STEPS_FEATURE_FLAG,
REGISTER_ALERT_VALIDATION_STEP_FEATURE_FLAG_DEFAULT
)
);

if (registerAlertValidationStepsEnabled) {
workflowsExtensions.registerStepDefinition(renderAlertNarrativeStepDefinition);
workflowsExtensions.registerStepDefinition(buildAlertEntityGraphStepDefinition);
}
workflowsExtensions.registerStepDefinition(async () => {
if (!(await isEnabled)) return undefined;
return import('./render_alert_narrative_step').then(
(m) => m.renderAlertNarrativeStepDefinition
);
});

workflowsExtensions.registerStepDefinition(async () => {
if (!(await isEnabled)) return undefined;
return import('./build_alert_entity_graph_step').then(
(m) => m.buildAlertEntityGraphStepDefinition
);
});
};
Original file line number Diff line number Diff line change
Expand Up @@ -820,20 +820,7 @@ export class Plugin implements ISecuritySolutionPlugin {
this.registerAgentBuilderAttachmentsAndTools(plugins, core, this.logger);

if (plugins.workflowsExtensions) {
const workflowsExtensions = plugins.workflowsExtensions;
core
.getStartServices()
.then(async ([coreStart]) => {
await registerWorkflowSteps(workflowsExtensions, coreStart);
})
.catch((error) => {
this.logger.error(
`[RegisterAlertValidationSteps] Error registering alert validation steps: ${error.message}`,
{
error: error.stack,
}
);
});
registerWorkflowSteps(plugins.workflowsExtensions, core);
}

setupAlertsCapabilitiesSwitcher({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/*
* 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 { coreMock } from '@kbn/core/server/mocks';
import type {
WorkflowsExtensionsServerPluginSetup,
ServerStepDefinition,
} from '@kbn/workflows-extensions/server';
import { registerWorkflowSteps } from './register_workflow_steps';
import { renderAlertNarrativeStepDefinition } from './render_alert_narrative_step';
import { buildAlertEntityGraphStepDefinition } from './build_alert_entity_graph_step';
import {
REGISTER_ALERT_VALIDATION_STEPS_FEATURE_FLAG,
REGISTER_ALERT_VALIDATION_STEP_FEATURE_FLAG_DEFAULT,
} from '../../../common/constants';

type StepLoader = () => Promise<ServerStepDefinition | undefined>;

const createWorkflowsExtensionsMock = (): jest.Mocked<WorkflowsExtensionsServerPluginSetup> => ({
registerStepDefinition: jest.fn(),
registerTriggerDefinition: jest.fn(),
registerTriggerEventHandler: jest.fn(),
});

describe('registerWorkflowSteps (server)', () => {
const buildCoreMock = (featureFlagEnabled: boolean) => {
const core = coreMock.createSetup();
const coreStart = coreMock.createStart();
coreStart.featureFlags.getBooleanValue.mockResolvedValue(featureFlagEnabled);
core.getStartServices.mockResolvedValue([coreStart, {}, {}]);
return { core, coreStart };
};

it('calls registerStepDefinition synchronously for both steps', () => {
const { core } = buildCoreMock(true);
const workflowsExtensions = createWorkflowsExtensionsMock();

registerWorkflowSteps(workflowsExtensions, core);

expect(workflowsExtensions.registerStepDefinition).toHaveBeenCalledTimes(2);
// getStartServices is called once eagerly to create the shared memoized promise
expect(core.getStartServices).toHaveBeenCalledTimes(1);
});

it('async loader returns step definitions when feature flag is enabled', async () => {
const { core } = buildCoreMock(true);
const workflowsExtensions = createWorkflowsExtensionsMock();

registerWorkflowSteps(workflowsExtensions, core);

const [loader1, loader2] = workflowsExtensions.registerStepDefinition.mock.calls.map(
([arg]) => arg as StepLoader
);

await expect(loader1()).resolves.toBe(renderAlertNarrativeStepDefinition);
await expect(loader2()).resolves.toBe(buildAlertEntityGraphStepDefinition);
});

it('async loader returns undefined when feature flag is disabled', async () => {
const { core } = buildCoreMock(false);
const workflowsExtensions = createWorkflowsExtensionsMock();

registerWorkflowSteps(workflowsExtensions, core);

const [loader1, loader2] = workflowsExtensions.registerStepDefinition.mock.calls.map(
([arg]) => arg as StepLoader
);

await expect(loader1()).resolves.toBeUndefined();
await expect(loader2()).resolves.toBeUndefined();
});

it('checks the feature flag exactly once even when both loaders resolve', async () => {
const { core, coreStart } = buildCoreMock(true);
const workflowsExtensions = createWorkflowsExtensionsMock();

registerWorkflowSteps(workflowsExtensions, core);

const [loader1, loader2] = workflowsExtensions.registerStepDefinition.mock.calls.map(
([arg]) => arg as StepLoader
);
await Promise.all([loader1(), loader2()]);

expect(coreStart.featureFlags.getBooleanValue).toHaveBeenCalledTimes(1);
expect(coreStart.featureFlags.getBooleanValue).toHaveBeenCalledWith(
REGISTER_ALERT_VALIDATION_STEPS_FEATURE_FLAG,
REGISTER_ALERT_VALIDATION_STEP_FEATURE_FLAG_DEFAULT
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*/

import type { WorkflowsExtensionsServerPluginSetup } from '@kbn/workflows-extensions/server';
import type { CoreStart } from '@kbn/core/server';
import type { CoreSetup } from '@kbn/core/server';
import { renderAlertNarrativeStepDefinition } from './render_alert_narrative_step';
import { buildAlertEntityGraphStepDefinition } from './build_alert_entity_graph_step';
import {
Expand All @@ -15,19 +15,30 @@ import {
} from '../../../common/constants';

/**
* Registers all security workflow steps with the workflowsExtensions plugin
* Registers all security workflow steps with the workflowsExtensions plugin.
* Registration is synchronous; each step uses an async loader to perform the
* feature-flag check at resolution time.
*/
export const registerWorkflowSteps = async (
export const registerWorkflowSteps = (
workflowsExtensions: WorkflowsExtensionsServerPluginSetup,
coreStart: CoreStart
): Promise<void> => {
const registerAlertValidationStepsEnabled = await coreStart.featureFlags.getBooleanValue(
REGISTER_ALERT_VALIDATION_STEPS_FEATURE_FLAG,
REGISTER_ALERT_VALIDATION_STEP_FEATURE_FLAG_DEFAULT
);
core: CoreSetup
): void => {
const isEnabled = core
.getStartServices()
.then(([coreStart]) =>
coreStart.featureFlags.getBooleanValue(
REGISTER_ALERT_VALIDATION_STEPS_FEATURE_FLAG,
REGISTER_ALERT_VALIDATION_STEP_FEATURE_FLAG_DEFAULT
)
);

if (registerAlertValidationStepsEnabled) {
workflowsExtensions.registerStepDefinition(renderAlertNarrativeStepDefinition);
workflowsExtensions.registerStepDefinition(buildAlertEntityGraphStepDefinition);
}
workflowsExtensions.registerStepDefinition(async () => {
if (!(await isEnabled)) return undefined;
return renderAlertNarrativeStepDefinition;
});

workflowsExtensions.registerStepDefinition(async () => {
if (!(await isEnabled)) return undefined;
return buildAlertEntityGraphStepDefinition;
});
};
Loading