[Actions] System actions MVP (#166267)

## Summary

A system action is an action that triggers Kibana workflows—for example,
creating a case, running an OsQuery, running an ML job, or logging. In
this PR:

- Enable rule routes to accept system actions. The schema of the action
is not changed. The framework deducts which action is a system action
automatically. System actions do not accept properties like the
`notifyWhen` or `group`.
- Enable rule client methods to accept system actions. The methods
accept a new property called `systemActions`. The methods merge the
actions with the system actions before persisting the rule to ES. The
methods split the actions from the system actions and return two arrays,
`actions` and `systemActions`.
- Introduce connector adapters: a way to transform the action params to
the corresponding connector params.
- Allow the execution of system actions. Only alert summaries are
supported. Users cannot control the execution of system actions.
- Register an example system action.
- Change the UI to handle system action. All configuration regarding
execution like "Run when" is hidden for system actions. Users cannot
select the same system action twice.

Closes https://github.com/elastic/kibana/issues/160367

This PR merges the system actions framework, a culmination of several
issues merged to the `system_actions_mvp` feature branch over the past
several months.

## Testing

A system action with ID `system-connector-.system-log-example` will be
available to be used by the APIs and the UI if you start Kibana with
`--run-examples`. Please ensure the following:

- You can create and update rules with actions and system actions.
- A rule with actions and system actions is executed as expected.
- Entries about the system action execution are added to the event log
as expected.
- Existing rules with actions work without issues (BWC).
- You can perform bulk actions in the rules table to rules with actions
and system actions.
- License restrictions are respected.
- Permission restrictions are respected.
- Disabled system actions cannot be used.
- Users cannot specify how the system action will run in the UI and the
API.

### Checklist

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [x] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))

---------

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Julia <iuliia.guskova@elastic.co>
Co-authored-by: Zacqary Xeper <zacqary.xeper@elastic.co>
Co-authored-by: Zacqary Adam Xeper <Zacqary@users.noreply.github.com>
Co-authored-by: Ying Mao <ying.mao@elastic.co>
Co-authored-by: Xavier Mouligneau <xavier.mouligneau@elastic.co>
This commit is contained in:
Christos Nasikas 2024-04-02 11:14:42 +03:00 committed by GitHub
parent 0dfa0d0eb4
commit 26d82227d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
264 changed files with 13676 additions and 2006 deletions

View file

@ -4,7 +4,7 @@
"owner": "@elastic/response-ops",
"plugin": {
"id": "triggersActionsUiExample",
"server": false,
"server": true,
"browser": true,
"requiredPlugins": [
"triggersActionsUi",
@ -12,10 +12,9 @@
"alerting",
"developerExamples",
"kibanaReact",
"cases"
],
"optionalPlugins": [
"spaces"
"cases",
"actions"
],
"optionalPlugins": ["spaces"]
}
}

View file

@ -0,0 +1,55 @@
/*
* 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 { lazy } from 'react';
import { i18n } from '@kbn/i18n';
import type {
ActionTypeModel as ConnectorTypeModel,
GenericValidationResult,
} from '@kbn/triggers-actions-ui-plugin/public/types';
import { SystemLogActionParams } from '../types';
export function getConnectorType(): ConnectorTypeModel<unknown, unknown, SystemLogActionParams> {
return {
id: '.system-log-example',
iconClass: 'logsApp',
selectMessage: i18n.translate(
'xpack.stackConnectors.components.systemLogExample.selectMessageText',
{
defaultMessage: 'Example of a system action that sends logs to the Kibana server',
}
),
actionTypeTitle: i18n.translate(
'xpack.stackConnectors.components.serverLog.connectorTypeTitle',
{
defaultMessage: 'Send to System log - Example',
}
),
validateParams: (
actionParams: SystemLogActionParams
): Promise<GenericValidationResult<Pick<SystemLogActionParams, 'message'>>> => {
const errors = {
message: new Array<string>(),
};
const validationResult = { errors };
if (!actionParams.message?.length) {
errors.message.push(
i18n.translate(
'xpack.stackConnectors.components.serverLog.error.requiredServerLogMessageText',
{
defaultMessage: 'Message is required.',
}
)
);
}
return Promise.resolve(validationResult);
},
actionConnectorFields: null,
actionParamsFields: lazy(() => import('./system_log_example_params')),
isSystemActionType: true,
};
}

View file

@ -0,0 +1,65 @@
/*
* 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 React, { useEffect, useState } from 'react';
import { i18n } from '@kbn/i18n';
import type { ActionParamsProps } from '@kbn/triggers-actions-ui-plugin/public';
import { TextAreaWithMessageVariables } from '@kbn/triggers-actions-ui-plugin/public';
import { SystemLogActionParams } from '../types';
export const ServerLogParamsFields: React.FunctionComponent<
ActionParamsProps<SystemLogActionParams>
> = ({
actionParams,
editAction,
index,
errors,
messageVariables,
defaultMessage,
useDefaultMessage,
}) => {
const { message } = actionParams;
const [[isUsingDefault, defaultMessageUsed], setDefaultMessageUsage] = useState<
[boolean, string | undefined]
>([false, defaultMessage]);
// This params component is derived primarily from server_log_params.tsx, see that file and its
// corresponding unit tests for details on functionality
useEffect(() => {
if (
useDefaultMessage ||
!actionParams?.message ||
(isUsingDefault &&
actionParams?.message === defaultMessageUsed &&
defaultMessageUsed !== defaultMessage)
) {
setDefaultMessageUsage([true, defaultMessage]);
editAction('message', defaultMessage, index);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [defaultMessage]);
return (
<TextAreaWithMessageVariables
index={index}
editAction={editAction}
messageVariables={messageVariables}
paramsProperty={'message'}
inputTargetValue={message}
label={i18n.translate(
'xpack.stackConnectors.components.systemLogExample.logMessageFieldLabel',
{
defaultMessage: 'Message',
}
)}
errors={errors.message as string[]}
/>
);
};
// eslint-disable-next-line import/no-default-export
export { ServerLogParamsFields as default };

View file

@ -5,9 +5,6 @@
* 2.0.
*/
export const filterStateStore = {
APP_STATE: 'appState',
GLOBAL_STATE: 'globalState',
} as const;
export type FilterStateStore = typeof filterStateStore[keyof typeof filterStateStore];
export interface SystemLogActionParams {
message: string;
}

View file

@ -23,6 +23,7 @@ import {
} from '@kbn/triggers-actions-ui-plugin/public/types';
import { SortCombinations } from '@elastic/elasticsearch/lib/api/types';
import { EuiDataGridColumn } from '@elastic/eui';
import { getConnectorType as getSystemLogExampleConnectorType } from './connector_types/system_log_example/system_log_example';
export interface TriggersActionsUiExamplePublicSetupDeps {
alerting: AlertingSetup;
@ -145,6 +146,8 @@ export class TriggersActionsUiExamplePlugin
};
alertsTableConfigurationRegistry.register(config);
triggersActionsUi.actionTypeRegistry.register(getSystemLogExampleConnectorType());
}
public stop() {}

View file

@ -0,0 +1,99 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { schema, TypeOf } from '@kbn/config-schema';
import { LogMeta } from '@kbn/core/server';
import type {
ActionType as ConnectorType,
ActionTypeExecutorOptions as ConnectorTypeExecutorOptions,
ActionTypeExecutorResult as ConnectorTypeExecutorResult,
} from '@kbn/actions-plugin/server/types';
import {
AlertingConnectorFeatureId,
UptimeConnectorFeatureId,
} from '@kbn/actions-plugin/common/connector_feature_config';
import { ConnectorAdapter } from '@kbn/alerting-plugin/server';
// see: https://en.wikipedia.org/wiki/Unicode_control_characters
// but don't include tabs (0x09), they're fine
const CONTROL_CHAR_PATTERN = /[\x00-\x08]|[\x0A-\x1F]|[\x7F-\x9F]|[\u2028-\u2029]/g;
// replaces control characters in string with ;, but leaves tabs
function withoutControlCharacters(s: string): string {
return s.replace(CONTROL_CHAR_PATTERN, ';');
}
export type ServerLogConnectorType = ConnectorType<{}, {}, ActionParamsType>;
export type ServerLogConnectorTypeExecutorOptions = ConnectorTypeExecutorOptions<
{},
{},
ActionParamsType
>;
// params definition
export type ActionParamsType = TypeOf<typeof ParamsSchema>;
const ParamsSchema = schema.object({
message: schema.string(),
});
export const ConnectorTypeId = '.system-log-example';
// connector type definition
export function getConnectorType(): ServerLogConnectorType {
return {
id: ConnectorTypeId,
isSystemActionType: true,
minimumLicenseRequired: 'gold', // Third party action types require at least gold
name: i18n.translate('xpack.stackConnectors.systemLogExample.title', {
defaultMessage: 'System log - example',
}),
supportedFeatureIds: [AlertingConnectorFeatureId, UptimeConnectorFeatureId],
validate: {
config: { schema: schema.object({}, { defaultValue: {} }) },
secrets: { schema: schema.object({}, { defaultValue: {} }) },
params: {
schema: ParamsSchema,
},
},
executor,
};
}
export const connectorAdapter: ConnectorAdapter = {
connectorTypeId: ConnectorTypeId,
ruleActionParamsSchema: ParamsSchema,
buildActionParams: ({ alerts, rule, params, spaceId, ruleUrl }) => {
return { ...params };
},
};
// action executor
async function executor(
execOptions: ServerLogConnectorTypeExecutorOptions
): Promise<ConnectorTypeExecutorResult<void>> {
const { actionId, params, logger } = execOptions;
const sanitizedMessage = withoutControlCharacters(params.message);
try {
logger.info<LogMeta>(`SYSTEM ACTION EXAMPLE Server log: ${sanitizedMessage}`);
} catch (err) {
const message = i18n.translate('xpack.stackConnectors.serverLog.errorLoggingErrorMessage', {
defaultMessage: 'error logging message',
});
return {
status: 'error',
message,
serviceMessage: err.message,
actionId,
};
}
return { status: 'ok', actionId };
}

View file

@ -0,0 +1,13 @@
/*
* 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 { PluginInitializer } from '@kbn/core/server';
export const plugin: PluginInitializer<void, void> = async () => {
const { TriggersActionsUiExamplePlugin } = await import('./plugin');
return new TriggersActionsUiExamplePlugin();
};

View file

@ -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 { Plugin, CoreSetup } from '@kbn/core/server';
import { PluginSetupContract as ActionsSetup } from '@kbn/actions-plugin/server';
import { PluginSetupContract as AlertingSetup } from '@kbn/alerting-plugin/server';
import {
getConnectorType as getSystemLogExampleConnectorType,
connectorAdapter as systemLogConnectorAdapter,
} from './connector_types/system_log_example';
// this plugin's dependencies
export interface TriggersActionsUiExampleDeps {
alerting: AlertingSetup;
actions: ActionsSetup;
}
export class TriggersActionsUiExamplePlugin
implements Plugin<void, void, TriggersActionsUiExampleDeps>
{
public setup(core: CoreSetup, { actions, alerting }: TriggersActionsUiExampleDeps) {
actions.registerType(getSystemLogExampleConnectorType());
alerting.registerConnectorAdapter(systemLogConnectorAdapter);
}
public start() {}
public stop() {}
}

View file

@ -23,5 +23,8 @@
"@kbn/data-plugin",
"@kbn/i18n-react",
"@kbn/shared-ux-router",
"@kbn/i18n",
"@kbn/actions-plugin",
"@kbn/config-schema",
]
}

View file

@ -495,7 +495,7 @@ describe('actionTypeRegistry', () => {
expect(actionTypeRegistry.isActionExecutable('my-slack1', 'foo')).toEqual(true);
});
test('should return false when isActionTypeEnabled is false and isLicenseValidForActionType is true and it has system connectors', async () => {
test('should return true when isActionTypeEnabled is false and isLicenseValidForActionType is true and it has system connectors', async () => {
mockedActionsConfig.isActionTypeEnabled.mockReturnValue(false);
mockedLicenseState.isLicenseValidForActionType.mockReturnValue({ isValid: true });
@ -504,7 +504,7 @@ describe('actionTypeRegistry', () => {
'system-connector-test.system-action',
'system-action-type'
)
).toEqual(false);
).toEqual(true);
});
test('should call isLicenseValidForActionType of the license state with notifyUsage false by default', async () => {

View file

@ -92,7 +92,11 @@ export class ActionTypeRegistry {
(connector) => connector.id === actionId
);
return actionTypeEnabled || (!actionTypeEnabled && inMemoryConnector?.isPreconfigured === true);
return (
actionTypeEnabled ||
(!actionTypeEnabled &&
(inMemoryConnector?.isPreconfigured === true || inMemoryConnector?.isSystemAction === true))
);
}
/**

View file

@ -18,6 +18,7 @@ const createActionsClientMock = () => {
delete: jest.fn(),
update: jest.fn(),
getAll: jest.fn(),
getAllSystemConnectors: jest.fn(),
getBulk: jest.fn(),
getOAuthAccessToken: jest.fn(),
execute: jest.fn(),

View file

@ -2797,7 +2797,7 @@ describe('execute()', () => {
});
expect(authorization.ensureAuthorized).toHaveBeenCalledWith({
actionTypeId: 'my-action-type',
actionTypeId: '.cases',
operation: 'execute',
additionalPrivileges: ['test/create'],
});
@ -2930,7 +2930,7 @@ describe('execute()', () => {
});
expect(authorization.ensureAuthorized).toHaveBeenCalledWith({
actionTypeId: 'my-action-type',
actionTypeId: '.cases',
operation: 'execute',
additionalPrivileges: ['test/create'],
});

View file

@ -91,6 +91,7 @@ import {
} from '../lib/get_execution_log_aggregation';
import { connectorFromSavedObject, isConnectorDeprecated } from '../application/connector/lib';
import { ListTypesParams } from '../application/connector/methods/list_types/types';
import { getAllSystemConnectors } from '../application/connector/methods/get_all/get_all';
interface ActionUpdate {
name: string;
@ -418,6 +419,13 @@ export class ActionsClient {
return getAll({ context: this.context, includeSystemActions });
}
/**
* Get all system connectors
*/
public async getAllSystemConnectors(): Promise<ConnectorWithExtraFindData[]> {
return getAllSystemConnectors({ context: this.context });
}
/**
* Get bulk actions with in-memory list
*/
@ -691,7 +699,7 @@ export class ActionsClient {
let actionTypeId: string | undefined;
try {
if (this.isPreconfigured(actionId)) {
if (this.isPreconfigured(actionId) || this.isSystemAction(actionId)) {
const connector = this.context.inMemoryConnectors.find(
(inMemoryConnector) => inMemoryConnector.id === actionId
);

View file

@ -113,8 +113,152 @@ describe('getAll()', () => {
getEventLogClient.mockResolvedValue(eventLogClient);
});
describe('authorization', () => {
function getAllOperation(): ReturnType<ActionsClient['getAll']> {
describe('getAll()', () => {
describe('authorization', () => {
function getAllOperation(): ReturnType<ActionsClient['getAll']> {
const expectedResult = {
total: 1,
per_page: 10,
page: 1,
saved_objects: [
{
id: '1',
type: 'type',
attributes: {
name: 'test',
config: {
foo: 'bar',
},
},
score: 1,
references: [],
},
],
};
unsecuredSavedObjectsClient.find.mockResolvedValueOnce(expectedResult);
scopedClusterClient.asInternalUser.search.mockResponse(
// @ts-expect-error not full search response
{
aggregations: {
'1': { doc_count: 6 },
testPreconfigured: { doc_count: 2 },
},
}
);
actionsClient = new ActionsClient({
logger,
actionTypeRegistry,
unsecuredSavedObjectsClient,
scopedClusterClient,
kibanaIndices,
actionExecutor,
ephemeralExecutionEnqueuer,
bulkExecutionEnqueuer,
request,
authorization: authorization as unknown as ActionsAuthorization,
inMemoryConnectors: [
{
id: 'testPreconfigured',
actionTypeId: '.slack',
secrets: {},
isPreconfigured: true,
isDeprecated: false,
isSystemAction: false,
name: 'test',
config: {
foo: 'bar',
},
},
],
connectorTokenClient: connectorTokenClientMock.create(),
getEventLogClient,
});
return actionsClient.getAll();
}
test('ensures user is authorised to get the type of action', async () => {
await getAllOperation();
expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ operation: 'get' });
});
test('throws when user is not authorised to create the type of action', async () => {
authorization.ensureAuthorized.mockRejectedValue(
new Error(`Unauthorized to get all actions`)
);
await expect(getAllOperation()).rejects.toMatchInlineSnapshot(
`[Error: Unauthorized to get all actions]`
);
expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ operation: 'get' });
});
});
describe('auditLogger', () => {
test('logs audit event when searching connectors', async () => {
unsecuredSavedObjectsClient.find.mockResolvedValueOnce({
total: 1,
per_page: 10,
page: 1,
saved_objects: [
{
id: '1',
type: 'type',
attributes: {
name: 'test',
isMissingSecrets: false,
config: {
foo: 'bar',
},
},
score: 1,
references: [],
},
],
});
scopedClusterClient.asInternalUser.search.mockResponse(
// @ts-expect-error not full search response
{
aggregations: {
'1': { doc_count: 6 },
testPreconfigured: { doc_count: 2 },
},
}
);
await actionsClient.getAll();
expect(auditLogger.log).toHaveBeenCalledWith(
expect.objectContaining({
event: expect.objectContaining({
action: 'connector_find',
outcome: 'success',
}),
kibana: { saved_object: { id: '1', type: 'action' } },
})
);
});
test('logs audit event when not authorised to search connectors', async () => {
authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized'));
await expect(actionsClient.getAll()).rejects.toThrow();
expect(auditLogger.log).toHaveBeenCalledWith(
expect.objectContaining({
event: expect.objectContaining({
action: 'connector_find',
outcome: 'failure',
}),
error: { code: 'Error', message: 'Unauthorized' },
})
);
});
});
test('calls unsecuredSavedObjectsClient with parameters and returns inMemoryConnectors correctly', async () => {
const expectedResult = {
total: 1,
per_page: 10,
@ -125,6 +269,7 @@ describe('getAll()', () => {
type: 'type',
attributes: {
name: 'test',
isMissingSecrets: false,
config: {
foo: 'bar',
},
@ -135,6 +280,215 @@ describe('getAll()', () => {
],
};
unsecuredSavedObjectsClient.find.mockResolvedValueOnce(expectedResult);
scopedClusterClient.asInternalUser.search.mockResponse(
// @ts-expect-error not full search response
{
aggregations: {
'1': { doc_count: 6 },
testPreconfigured: { doc_count: 2 },
'system-connector-.cases': { doc_count: 2 },
},
}
);
actionsClient = new ActionsClient({
logger,
actionTypeRegistry,
unsecuredSavedObjectsClient,
scopedClusterClient,
kibanaIndices,
actionExecutor,
ephemeralExecutionEnqueuer,
bulkExecutionEnqueuer,
request,
authorization: authorization as unknown as ActionsAuthorization,
inMemoryConnectors: [
{
id: 'testPreconfigured',
actionTypeId: '.slack',
secrets: {},
isPreconfigured: true,
isDeprecated: false,
isSystemAction: false,
name: 'test',
config: {
foo: 'bar',
},
},
/**
* System actions will not
* be returned from getAll
* if no options are provided
*/
{
id: 'system-connector-.cases',
actionTypeId: '.cases',
name: 'System action: .cases',
config: {},
secrets: {},
isDeprecated: false,
isMissingSecrets: false,
isPreconfigured: false,
isSystemAction: true,
},
],
connectorTokenClient: connectorTokenClientMock.create(),
getEventLogClient,
});
const result = await actionsClient.getAll();
expect(result).toEqual([
{
id: '1',
name: 'test',
isMissingSecrets: false,
config: { foo: 'bar' },
isPreconfigured: false,
isDeprecated: false,
isSystemAction: false,
referencedByCount: 6,
},
{
id: 'testPreconfigured',
actionTypeId: '.slack',
name: 'test',
isPreconfigured: true,
isSystemAction: false,
isDeprecated: false,
referencedByCount: 2,
},
]);
});
test('get system actions correctly', async () => {
const expectedResult = {
total: 1,
per_page: 10,
page: 1,
saved_objects: [
{
id: '1',
type: 'type',
attributes: {
name: 'test',
isMissingSecrets: false,
config: {
foo: 'bar',
},
},
score: 1,
references: [],
},
],
};
unsecuredSavedObjectsClient.find.mockResolvedValueOnce(expectedResult);
scopedClusterClient.asInternalUser.search.mockResponse(
// @ts-expect-error not full search response
{
aggregations: {
'1': { doc_count: 6 },
testPreconfigured: { doc_count: 2 },
'system-connector-.cases': { doc_count: 2 },
},
}
);
actionsClient = new ActionsClient({
logger,
actionTypeRegistry,
unsecuredSavedObjectsClient,
scopedClusterClient,
kibanaIndices,
actionExecutor,
ephemeralExecutionEnqueuer,
bulkExecutionEnqueuer,
request,
authorization: authorization as unknown as ActionsAuthorization,
inMemoryConnectors: [
{
id: 'testPreconfigured',
actionTypeId: '.slack',
secrets: {},
isPreconfigured: true,
isDeprecated: false,
isSystemAction: false,
name: 'test',
config: {
foo: 'bar',
},
},
{
id: 'system-connector-.cases',
actionTypeId: '.cases',
name: 'System action: .cases',
config: {},
secrets: {},
isDeprecated: false,
isMissingSecrets: false,
isPreconfigured: false,
isSystemAction: true,
},
],
connectorTokenClient: connectorTokenClientMock.create(),
getEventLogClient,
});
const result = await actionsClient.getAll({ includeSystemActions: true });
expect(result).toEqual([
{
actionTypeId: '.cases',
id: 'system-connector-.cases',
isDeprecated: false,
isPreconfigured: false,
isSystemAction: true,
name: 'System action: .cases',
referencedByCount: 2,
},
{
id: '1',
name: 'test',
isMissingSecrets: false,
config: { foo: 'bar' },
isPreconfigured: false,
isDeprecated: false,
isSystemAction: false,
referencedByCount: 6,
},
{
id: 'testPreconfigured',
actionTypeId: '.slack',
name: 'test',
isPreconfigured: true,
isSystemAction: false,
isDeprecated: false,
referencedByCount: 2,
},
]);
});
test('validates connectors before return', async () => {
unsecuredSavedObjectsClient.find.mockResolvedValueOnce({
total: 1,
per_page: 10,
page: 1,
saved_objects: [
{
id: '1',
type: 'type',
attributes: {
name: 'test',
isMissingSecrets: false,
config: {
foo: 'bar',
},
},
score: 1,
references: [],
},
],
});
scopedClusterClient.asInternalUser.search.mockResponse(
// @ts-expect-error not full search response
{
@ -173,387 +527,183 @@ describe('getAll()', () => {
connectorTokenClient: connectorTokenClientMock.create(),
getEventLogClient,
});
return actionsClient.getAll();
}
test('ensures user is authorised to get the type of action', async () => {
await getAllOperation();
expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ operation: 'get' });
});
const result = await actionsClient.getAll({ includeSystemActions: true });
expect(result).toEqual([
{
config: {
foo: 'bar',
},
id: '1',
isDeprecated: false,
isMissingSecrets: false,
isPreconfigured: false,
isSystemAction: false,
name: 'test',
referencedByCount: 6,
},
{
actionTypeId: '.slack',
id: 'testPreconfigured',
isDeprecated: false,
isPreconfigured: true,
isSystemAction: false,
name: 'test',
referencedByCount: 2,
},
]);
test('throws when user is not authorised to create the type of action', async () => {
authorization.ensureAuthorized.mockRejectedValue(
new Error(`Unauthorized to get all actions`)
expect(logger.warn).toHaveBeenCalledWith(
'Error validating connector: 1, Error: [actionTypeId]: expected value of type [string] but got [undefined]'
);
await expect(getAllOperation()).rejects.toMatchInlineSnapshot(
`[Error: Unauthorized to get all actions]`
);
expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ operation: 'get' });
});
});
describe('auditLogger', () => {
test('logs audit event when searching connectors', async () => {
unsecuredSavedObjectsClient.find.mockResolvedValueOnce({
total: 1,
per_page: 10,
page: 1,
saved_objects: [
describe('getAllSystemConnectors()', () => {
describe('authorization', () => {
function getAllOperation(): ReturnType<ActionsClient['getAll']> {
scopedClusterClient.asInternalUser.search.mockResponse(
// @ts-expect-error not full search response
{
id: '1',
type: 'type',
attributes: {
name: 'test',
isMissingSecrets: false,
config: {
foo: 'bar',
},
aggregations: {
'system-connector-.test': { doc_count: 2 },
},
score: 1,
references: [],
},
],
}
);
actionsClient = new ActionsClient({
logger,
actionTypeRegistry,
unsecuredSavedObjectsClient,
scopedClusterClient,
kibanaIndices,
actionExecutor,
ephemeralExecutionEnqueuer,
bulkExecutionEnqueuer,
request,
authorization: authorization as unknown as ActionsAuthorization,
inMemoryConnectors: [
{
id: 'system-connector-.test',
actionTypeId: '.test',
name: 'Test system action',
config: {},
secrets: {},
isDeprecated: false,
isMissingSecrets: false,
isPreconfigured: false,
isSystemAction: true,
},
],
connectorTokenClient: connectorTokenClientMock.create(),
getEventLogClient,
});
return actionsClient.getAllSystemConnectors();
}
test('ensures user is authorised to get the type of action', async () => {
await getAllOperation();
expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ operation: 'get' });
});
test('throws when user is not authorised to get the type of action', async () => {
authorization.ensureAuthorized.mockRejectedValue(
new Error(`Unauthorized to get all actions`)
);
await expect(getAllOperation()).rejects.toMatchInlineSnapshot(
`[Error: Unauthorized to get all actions]`
);
expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ operation: 'get' });
});
});
describe('auditLogger', () => {
test('logs audit event when not authorised to search connectors', async () => {
authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized'));
await expect(actionsClient.getAllSystemConnectors()).rejects.toThrow();
expect(auditLogger.log).toHaveBeenCalledWith(
expect.objectContaining({
event: expect.objectContaining({
action: 'connector_find',
outcome: 'failure',
}),
error: { code: 'Error', message: 'Unauthorized' },
})
);
});
});
test('get all system actions correctly', async () => {
scopedClusterClient.asInternalUser.search.mockResponse(
// @ts-expect-error not full search response
{
aggregations: {
'1': { doc_count: 6 },
testPreconfigured: { doc_count: 2 },
'system-connector-.test': { doc_count: 2 },
},
}
);
await actionsClient.getAll();
expect(auditLogger.log).toHaveBeenCalledWith(
expect.objectContaining({
event: expect.objectContaining({
action: 'connector_find',
outcome: 'success',
}),
kibana: { saved_object: { id: '1', type: 'action' } },
})
);
});
test('logs audit event when not authorised to search connectors', async () => {
authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized'));
await expect(actionsClient.getAll()).rejects.toThrow();
expect(auditLogger.log).toHaveBeenCalledWith(
expect.objectContaining({
event: expect.objectContaining({
action: 'connector_find',
outcome: 'failure',
}),
error: { code: 'Error', message: 'Unauthorized' },
})
);
});
});
test('calls unsecuredSavedObjectsClient with parameters and returns inMemoryConnectors correctly', async () => {
const expectedResult = {
total: 1,
per_page: 10,
page: 1,
saved_objects: [
{
id: '1',
type: 'type',
attributes: {
actionsClient = new ActionsClient({
logger,
actionTypeRegistry,
unsecuredSavedObjectsClient,
scopedClusterClient,
kibanaIndices,
actionExecutor,
ephemeralExecutionEnqueuer,
bulkExecutionEnqueuer,
request,
authorization: authorization as unknown as ActionsAuthorization,
inMemoryConnectors: [
{
id: 'testPreconfigured',
actionTypeId: 'my-action-type',
secrets: {
test: 'test1',
},
isPreconfigured: true,
isDeprecated: false,
isSystemAction: false,
name: 'test',
isMissingSecrets: false,
config: {
foo: 'bar',
},
},
score: 1,
references: [],
},
],
};
unsecuredSavedObjectsClient.find.mockResolvedValueOnce(expectedResult);
scopedClusterClient.asInternalUser.search.mockResponse(
// @ts-expect-error not full search response
{
aggregations: {
'1': { doc_count: 6 },
testPreconfigured: { doc_count: 2 },
'system-connector-.cases': { doc_count: 2 },
},
}
);
actionsClient = new ActionsClient({
logger,
actionTypeRegistry,
unsecuredSavedObjectsClient,
scopedClusterClient,
kibanaIndices,
actionExecutor,
ephemeralExecutionEnqueuer,
bulkExecutionEnqueuer,
request,
authorization: authorization as unknown as ActionsAuthorization,
inMemoryConnectors: [
{
id: 'testPreconfigured',
actionTypeId: '.slack',
secrets: {},
isPreconfigured: true,
isDeprecated: false,
isSystemAction: false,
name: 'test',
config: {
foo: 'bar',
{
id: 'system-connector-.test',
actionTypeId: '.test',
name: 'Test system action',
config: {},
secrets: {},
isDeprecated: false,
isMissingSecrets: false,
isPreconfigured: false,
isSystemAction: true,
},
},
/**
* System actions will not
* be returned from getAll
* if no options are provided
*/
],
connectorTokenClient: connectorTokenClientMock.create(),
getEventLogClient,
});
const result = await actionsClient.getAllSystemConnectors();
expect(result).toEqual([
{
id: 'system-connector-.cases',
actionTypeId: '.cases',
name: 'System action: .cases',
config: {},
secrets: {},
isDeprecated: false,
isMissingSecrets: false,
id: 'system-connector-.test',
actionTypeId: '.test',
name: 'Test system action',
isPreconfigured: false,
isDeprecated: false,
isSystemAction: true,
referencedByCount: 2,
},
],
connectorTokenClient: connectorTokenClientMock.create(),
getEventLogClient,
]);
});
const result = await actionsClient.getAll();
expect(result).toEqual([
{
id: '1',
name: 'test',
isMissingSecrets: false,
config: { foo: 'bar' },
isPreconfigured: false,
isDeprecated: false,
isSystemAction: false,
referencedByCount: 6,
},
{
id: 'testPreconfigured',
actionTypeId: '.slack',
name: 'test',
isPreconfigured: true,
isSystemAction: false,
isDeprecated: false,
referencedByCount: 2,
},
]);
});
test('get system actions correctly', async () => {
const expectedResult = {
total: 1,
per_page: 10,
page: 1,
saved_objects: [
{
id: '1',
type: 'type',
attributes: {
name: 'test',
isMissingSecrets: false,
config: {
foo: 'bar',
},
},
score: 1,
references: [],
},
],
};
unsecuredSavedObjectsClient.find.mockResolvedValueOnce(expectedResult);
scopedClusterClient.asInternalUser.search.mockResponse(
// @ts-expect-error not full search response
{
aggregations: {
'1': { doc_count: 6 },
testPreconfigured: { doc_count: 2 },
'system-connector-.cases': { doc_count: 2 },
},
}
);
actionsClient = new ActionsClient({
logger,
actionTypeRegistry,
unsecuredSavedObjectsClient,
scopedClusterClient,
kibanaIndices,
actionExecutor,
ephemeralExecutionEnqueuer,
bulkExecutionEnqueuer,
request,
authorization: authorization as unknown as ActionsAuthorization,
inMemoryConnectors: [
{
id: 'testPreconfigured',
actionTypeId: '.slack',
secrets: {},
isPreconfigured: true,
isDeprecated: false,
isSystemAction: false,
name: 'test',
config: {
foo: 'bar',
},
},
{
id: 'system-connector-.cases',
actionTypeId: '.cases',
name: 'System action: .cases',
config: {},
secrets: {},
isDeprecated: false,
isMissingSecrets: false,
isPreconfigured: false,
isSystemAction: true,
},
],
connectorTokenClient: connectorTokenClientMock.create(),
getEventLogClient,
});
const result = await actionsClient.getAll({ includeSystemActions: true });
expect(result).toEqual([
{
actionTypeId: '.cases',
id: 'system-connector-.cases',
isDeprecated: false,
isPreconfigured: false,
isSystemAction: true,
name: 'System action: .cases',
referencedByCount: 2,
},
{
id: '1',
name: 'test',
isMissingSecrets: false,
config: { foo: 'bar' },
isPreconfigured: false,
isDeprecated: false,
isSystemAction: false,
referencedByCount: 6,
},
{
id: 'testPreconfigured',
actionTypeId: '.slack',
name: 'test',
isPreconfigured: true,
isSystemAction: false,
isDeprecated: false,
referencedByCount: 2,
},
]);
});
test('validates connectors before return', async () => {
unsecuredSavedObjectsClient.find.mockResolvedValueOnce({
total: 1,
per_page: 10,
page: 1,
saved_objects: [
{
id: '1',
type: 'type',
attributes: {
name: 'test',
isMissingSecrets: false,
config: {
foo: 'bar',
},
},
score: 1,
references: [],
},
],
});
scopedClusterClient.asInternalUser.search.mockResponse(
// @ts-expect-error not full search response
{
aggregations: {
'1': { doc_count: 6 },
testPreconfigured: { doc_count: 2 },
},
}
);
actionsClient = new ActionsClient({
logger,
actionTypeRegistry,
unsecuredSavedObjectsClient,
scopedClusterClient,
kibanaIndices,
actionExecutor,
ephemeralExecutionEnqueuer,
bulkExecutionEnqueuer,
request,
authorization: authorization as unknown as ActionsAuthorization,
inMemoryConnectors: [
{
id: 'testPreconfigured',
actionTypeId: '.slack',
secrets: {},
isPreconfigured: true,
isDeprecated: false,
isSystemAction: false,
name: 'test',
config: {
foo: 'bar',
},
},
],
connectorTokenClient: connectorTokenClientMock.create(),
getEventLogClient,
});
const result = await actionsClient.getAll({ includeSystemActions: true });
expect(result).toEqual([
{
config: {
foo: 'bar',
},
id: '1',
isDeprecated: false,
isMissingSecrets: false,
isPreconfigured: false,
isSystemAction: false,
name: 'test',
referencedByCount: 6,
},
{
actionTypeId: '.slack',
id: 'testPreconfigured',
isDeprecated: false,
isPreconfigured: true,
isSystemAction: false,
name: 'test',
referencedByCount: 2,
},
]);
expect(logger.warn).toHaveBeenCalledWith(
'Error validating connector: 1, Error: [actionTypeId]: expected value of type [string] but got [undefined]'
);
});
});

View file

@ -127,6 +127,12 @@ async function getAllHelper({
connectors: mergedResult,
});
validateConnectors(connectors, logger);
return connectors;
}
const validateConnectors = (connectors: ConnectorWithExtraFindData[], logger: Logger) => {
connectors.forEach((connector) => {
// Try to validate the connectors, but don't throw.
try {
@ -135,6 +141,48 @@ async function getAllHelper({
logger.warn(`Error validating connector: ${connector.id}, ${e}`);
}
});
};
export async function getAllSystemConnectors({
context,
}: {
context: GetAllParams['context'];
}): Promise<ConnectorWithExtraFindData[]> {
try {
await context.authorization.ensureAuthorized({ operation: 'get' });
} catch (error) {
context.auditLogger?.log(
connectorAuditEvent({
action: ConnectorAuditAction.FIND,
error,
})
);
throw error;
}
const systemConnectors = context.inMemoryConnectors.filter(
(connector) => connector.isSystemAction
);
const transformedSystemConnectors = systemConnectors
.map((systemConnector) => ({
id: systemConnector.id,
actionTypeId: systemConnector.actionTypeId,
name: systemConnector.name,
isPreconfigured: systemConnector.isPreconfigured,
isDeprecated: isConnectorDeprecated(systemConnector),
isSystemAction: systemConnector.isSystemAction,
}))
.sort((a, b) => a.name.localeCompare(b.name));
const connectors = await injectExtraFindData({
kibanaIndices: context.kibanaIndices,
esClient: context.scopedClusterClient.asInternalUser,
connectors: transformedSystemConnectors,
});
validateConnectors(connectors, context.logger);
return connectors;
}

View file

@ -13,7 +13,7 @@ import {
} from '@kbn/core/server/mocks';
import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks';
import { Logger } from '@kbn/core/server';
import { actionsClientMock } from './actions_client/actions_client.mock';
import { actionsClientMock, ActionsClientMock } from './actions_client/actions_client.mock';
import { PluginSetupContract, PluginStartContract, renderActionParameterTemplates } from './plugin';
import { Services, UnsecuredServices } from './types';
import { actionsAuthorizationMock } from './authorization/actions_authorization.mock';
@ -21,6 +21,8 @@ import { ConnectorTokenClient } from './lib/connector_token_client';
import { unsecuredActionsClientMock } from './unsecured_actions_client/unsecured_actions_client.mock';
export { actionsAuthorizationMock };
export { actionsClientMock };
export type { ActionsClientMock };
const logger = loggingSystemMock.create().get() as jest.Mocked<Logger>;
const createSetupMock = () => {
@ -49,6 +51,7 @@ const createStartMock = () => {
.mockReturnValue(actionsAuthorizationMock.create()),
inMemoryConnectors: [],
renderActionParameterTemplates: jest.fn(),
isSystemActionConnector: jest.fn(),
};
return mock;
};

View file

@ -896,5 +896,53 @@ describe('Actions Plugin', () => {
expect(pluginSetup.getActionsHealth()).toEqual({ hasPermanentEncryptionKey: true });
});
});
describe('isSystemActionConnector()', () => {
it('should return true if the connector is a system connector', async () => {
// coreMock.createSetup doesn't support Plugin generics
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pluginSetup = await plugin.setup(coreSetup as any, pluginsSetup);
pluginSetup.registerType({
id: '.cases',
name: 'Cases',
minimumLicenseRequired: 'platinum',
supportedFeatureIds: ['alerting'],
validate: {
config: { schema: schema.object({}) },
secrets: { schema: schema.object({}) },
params: { schema: schema.object({}) },
},
isSystemActionType: true,
executor,
});
const pluginStart = await plugin.start(coreStart, pluginsStart);
expect(pluginStart.isSystemActionConnector('system-connector-.cases')).toBe(true);
});
it('should return false if the connector is not a system connector', async () => {
// coreMock.createSetup doesn't support Plugin generics
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pluginSetup = await plugin.setup(coreSetup as any, pluginsSetup);
pluginSetup.registerType({
id: '.cases',
name: 'Cases',
minimumLicenseRequired: 'platinum',
supportedFeatureIds: ['alerting'],
validate: {
config: { schema: schema.object({}) },
secrets: { schema: schema.object({}) },
params: { schema: schema.object({}) },
},
isSystemActionType: true,
executor,
});
const pluginStart = await plugin.start(coreStart, pluginsStart);
expect(pluginStart.isSystemActionConnector('preconfiguredServerLog')).toBe(false);
});
});
});
});

View file

@ -159,6 +159,7 @@ export interface PluginStartContract {
params: Params,
variables: Record<string, unknown>
): Params;
isSystemActionConnector: (connectorId: string) => boolean;
}
export interface ActionsPluginsSetup {
@ -603,6 +604,12 @@ export class ActionsPlugin implements Plugin<PluginSetupContract, PluginStartCon
inMemoryConnectors: this.inMemoryConnectors,
renderActionParameterTemplates: (...args) =>
renderActionParameterTemplates(this.logger, actionTypeRegistry, ...args),
isSystemActionConnector: (connectorId: string): boolean => {
return this.inMemoryConnectors.some(
(inMemoryConnector) =>
inMemoryConnector.isSystemAction && inMemoryConnector.id === connectorId
);
},
};
}

View file

@ -0,0 +1,132 @@
/*
* 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 { getAllConnectorsIncludingSystemRoute } from './get_all_system';
import { httpServiceMock } from '@kbn/core/server/mocks';
import { licenseStateMock } from '../../../lib/license_state.mock';
import { mockHandlerArguments } from '../../legacy/_mock_handler_arguments';
import { verifyAccessAndContext } from '../../verify_access_and_context';
import { actionsClientMock } from '../../../actions_client/actions_client.mock';
jest.mock('../../verify_access_and_context', () => ({
verifyAccessAndContext: jest.fn(),
}));
beforeEach(() => {
jest.resetAllMocks();
(verifyAccessAndContext as jest.Mock).mockImplementation((license, handler) => handler);
});
describe('getAllConnectorsIncludingSystemRoute', () => {
it('get all connectors with proper parameters', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
getAllConnectorsIncludingSystemRoute(router, licenseState);
const [config, handler] = router.get.mock.calls[0];
expect(config.path).toMatchInlineSnapshot(`"/internal/actions/connectors"`);
const actionsClient = actionsClientMock.create();
actionsClient.getAll.mockResolvedValueOnce([
{
id: '.system-action-id',
isPreconfigured: false,
isSystemAction: true,
isDeprecated: false,
name: 'my system action',
actionTypeId: '.system-action-type',
isMissingSecrets: false,
config: {},
referencedByCount: 0,
},
]);
const [context, req, res] = mockHandlerArguments({ actionsClient }, {}, ['ok']);
expect(await handler(context, req, res)).toMatchInlineSnapshot(`
Object {
"body": Array [
Object {
"config": Object {},
"connector_type_id": ".system-action-type",
"id": ".system-action-id",
"is_deprecated": false,
"is_missing_secrets": false,
"is_preconfigured": false,
"is_system_action": true,
"name": "my system action",
"referenced_by_count": 0,
},
],
}
`);
expect(actionsClient.getAll).toHaveBeenCalledWith({ includeSystemActions: true });
expect(res.ok).toHaveBeenCalledWith({
body: [
{
config: {},
connector_type_id: '.system-action-type',
id: '.system-action-id',
is_deprecated: false,
is_missing_secrets: false,
is_preconfigured: false,
is_system_action: true,
name: 'my system action',
referenced_by_count: 0,
},
],
});
});
it('ensures the license allows getting all connectors', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
getAllConnectorsIncludingSystemRoute(router, licenseState);
const [config, handler] = router.get.mock.calls[0];
expect(config.path).toMatchInlineSnapshot(`"/internal/actions/connectors"`);
const actionsClient = actionsClientMock.create();
actionsClient.getAll.mockResolvedValueOnce([]);
const [context, req, res] = mockHandlerArguments({ actionsClient }, {}, ['ok']);
await handler(context, req, res);
expect(verifyAccessAndContext).toHaveBeenCalledWith(licenseState, expect.any(Function));
});
it('ensures the license check prevents getting all connectors', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
(verifyAccessAndContext as jest.Mock).mockImplementation(() => async () => {
throw new Error('OMG');
});
getAllConnectorsIncludingSystemRoute(router, licenseState);
const [config, handler] = router.get.mock.calls[0];
expect(config.path).toMatchInlineSnapshot(`"/internal/actions/connectors"`);
const actionsClient = actionsClientMock.create();
actionsClient.getAll.mockResolvedValueOnce([]);
const [context, req, res] = mockHandlerArguments({ actionsClient }, {}, ['ok']);
expect(handler(context, req, res)).rejects.toMatchInlineSnapshot(`[Error: OMG]`);
expect(verifyAccessAndContext).toHaveBeenCalledWith(licenseState, expect.any(Function));
});
});

View file

@ -0,0 +1,37 @@
/*
* 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 { IRouter } from '@kbn/core/server';
import { AllConnectorsResponseV1 } from '../../../../common/routes/connector/response';
import { ActionsRequestHandlerContext } from '../../../types';
import { INTERNAL_BASE_ACTION_API_PATH } from '../../../../common';
import { ILicenseState } from '../../../lib';
import { verifyAccessAndContext } from '../../verify_access_and_context';
import { transformGetAllConnectorsResponseV1 } from '../get_all/transforms';
export const getAllConnectorsIncludingSystemRoute = (
router: IRouter<ActionsRequestHandlerContext>,
licenseState: ILicenseState
) => {
router.get(
{
path: `${INTERNAL_BASE_ACTION_API_PATH}/connectors`,
validate: {},
},
router.handleLegacyErrors(
verifyAccessAndContext(licenseState, async function (context, req, res) {
const actionsClient = (await context.actions).getActionsClient();
const result = await actionsClient.getAll({
includeSystemActions: true,
});
const responseBody: AllConnectorsResponseV1[] = transformGetAllConnectorsResponseV1(result);
return res.ok({ body: responseBody });
})
)
);
};

View file

@ -5,9 +5,4 @@
* 2.0.
*/
export const filterStateStore = {
APP_STATE: 'appState',
GLOBAL_STATE: 'globalState',
} as const;
export type FilterStateStore = typeof filterStateStore[keyof typeof filterStateStore];
export { getAllConnectorsIncludingSystemRoute } from './get_all_system';

View file

@ -35,7 +35,9 @@ export const listTypesRoute = (
// Assert versioned inputs
const query: ConnectorTypesRequestQueryV1 = req.query;
const connectorTypes = await actionsClient.listTypes({ featureId: query?.feature_id });
const connectorTypes = await actionsClient.listTypes({
featureId: query?.feature_id,
});
const responseBody: ConnectorTypesResponseV1[] =
transformListTypesResponseV1(connectorTypes);

View file

@ -0,0 +1,8 @@
/*
* 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 { listTypesWithSystemRoute } from './list_types_system';

View file

@ -0,0 +1,249 @@
/*
* 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 { httpServiceMock } from '@kbn/core/server/mocks';
import { LicenseType } from '@kbn/licensing-plugin/server';
import { licenseStateMock } from '../../../lib/license_state.mock';
import { mockHandlerArguments } from '../../legacy/_mock_handler_arguments';
import { listTypesWithSystemRoute } from './list_types_system';
import { verifyAccessAndContext } from '../../verify_access_and_context';
import { actionsClientMock } from '../../../mocks';
jest.mock('../../verify_access_and_context', () => ({
verifyAccessAndContext: jest.fn(),
}));
beforeEach(() => {
jest.resetAllMocks();
(verifyAccessAndContext as jest.Mock).mockImplementation((license, handler) => handler);
});
describe('listTypesWithSystemRoute', () => {
it('lists action types with proper parameters', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
listTypesWithSystemRoute(router, licenseState);
const [config, handler] = router.get.mock.calls[0];
expect(config.path).toMatchInlineSnapshot(`"/internal/actions/connector_types"`);
const listTypes = [
{
id: '1',
name: 'name',
enabled: true,
enabledInConfig: true,
enabledInLicense: true,
minimumLicenseRequired: 'gold' as LicenseType,
supportedFeatureIds: ['alerting'],
isSystemActionType: true,
},
];
const actionsClient = actionsClientMock.create();
actionsClient.listTypes.mockResolvedValueOnce(listTypes);
const [context, req, res] = mockHandlerArguments({ actionsClient }, {}, ['ok']);
expect(await handler(context, req, res)).toMatchInlineSnapshot(`
Object {
"body": Array [
Object {
"enabled": true,
"enabled_in_config": true,
"enabled_in_license": true,
"id": "1",
"is_system_action_type": true,
"minimum_license_required": "gold",
"name": "name",
"supported_feature_ids": Array [
"alerting",
],
},
],
}
`);
expect(res.ok).toHaveBeenCalledWith({
body: [
{
id: '1',
name: 'name',
enabled: true,
enabled_in_config: true,
enabled_in_license: true,
supported_feature_ids: ['alerting'],
minimum_license_required: 'gold',
is_system_action_type: true,
},
],
});
});
it('passes feature_id if provided as query parameter', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
listTypesWithSystemRoute(router, licenseState);
const [config, handler] = router.get.mock.calls[0];
expect(config.path).toMatchInlineSnapshot(`"/internal/actions/connector_types"`);
const listTypes = [
{
id: '1',
name: 'name',
enabled: true,
enabledInConfig: true,
enabledInLicense: true,
supportedFeatureIds: ['alerting'],
minimumLicenseRequired: 'gold' as LicenseType,
isSystemActionType: false,
},
];
const actionsClient = actionsClientMock.create();
actionsClient.listTypes.mockResolvedValueOnce(listTypes);
const [context, req, res] = mockHandlerArguments(
{ actionsClient },
{
query: {
feature_id: 'alerting',
},
},
['ok']
);
expect(await handler(context, req, res)).toMatchInlineSnapshot(`
Object {
"body": Array [
Object {
"enabled": true,
"enabled_in_config": true,
"enabled_in_license": true,
"id": "1",
"is_system_action_type": false,
"minimum_license_required": "gold",
"name": "name",
"supported_feature_ids": Array [
"alerting",
],
},
],
}
`);
expect(actionsClient.listTypes).toHaveBeenCalledTimes(1);
expect(actionsClient.listTypes.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"featureId": "alerting",
"includeSystemActionTypes": true,
},
]
`);
expect(res.ok).toHaveBeenCalledWith({
body: [
{
id: '1',
name: 'name',
enabled: true,
enabled_in_config: true,
enabled_in_license: true,
supported_feature_ids: ['alerting'],
minimum_license_required: 'gold',
is_system_action_type: false,
},
],
});
});
it('ensures the license allows listing action types', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
listTypesWithSystemRoute(router, licenseState);
const [config, handler] = router.get.mock.calls[0];
expect(config.path).toMatchInlineSnapshot(`"/internal/actions/connector_types"`);
const listTypes = [
{
id: '1',
name: 'name',
enabled: true,
enabledInConfig: true,
enabledInLicense: true,
supportedFeatureIds: ['alerting'],
minimumLicenseRequired: 'gold' as LicenseType,
isSystemActionType: false,
},
];
const actionsClient = actionsClientMock.create();
actionsClient.listTypes.mockResolvedValueOnce(listTypes);
const [context, req, res] = mockHandlerArguments(
{ actionsClient },
{
params: { id: '1' },
},
['ok']
);
await handler(context, req, res);
expect(verifyAccessAndContext).toHaveBeenCalledWith(licenseState, expect.any(Function));
});
it('ensures the license check prevents listing action types', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
(verifyAccessAndContext as jest.Mock).mockImplementation(() => async () => {
throw new Error('OMG');
});
listTypesWithSystemRoute(router, licenseState);
const [config, handler] = router.get.mock.calls[0];
expect(config.path).toMatchInlineSnapshot(`"/internal/actions/connector_types"`);
const listTypes = [
{
id: '1',
name: 'name',
enabled: true,
enabledInConfig: true,
enabledInLicense: true,
supportedFeatureIds: ['alerting'],
minimumLicenseRequired: 'gold' as LicenseType,
isSystemActionType: false,
},
];
const actionsClient = actionsClientMock.create();
actionsClient.listTypes.mockResolvedValueOnce(listTypes);
const [context, req, res] = mockHandlerArguments(
{ actionsClient },
{
params: { id: '1' },
},
['ok']
);
expect(handler(context, req, res)).rejects.toMatchInlineSnapshot(`[Error: OMG]`);
expect(verifyAccessAndContext).toHaveBeenCalledWith(licenseState, expect.any(Function));
});
});

View file

@ -0,0 +1,50 @@
/*
* 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 { IRouter } from '@kbn/core/server';
import { ConnectorTypesResponseV1 } from '../../../../common/routes/connector/response';
import {
connectorTypesQuerySchemaV1,
ConnectorTypesRequestQueryV1,
} from '../../../../common/routes/connector/apis/connector_types';
import { ActionsRequestHandlerContext } from '../../../types';
import { INTERNAL_BASE_ACTION_API_PATH } from '../../../../common';
import { ILicenseState } from '../../../lib';
import { verifyAccessAndContext } from '../../verify_access_and_context';
import { transformListTypesResponseV1 } from '../list_types/transforms';
export const listTypesWithSystemRoute = (
router: IRouter<ActionsRequestHandlerContext>,
licenseState: ILicenseState
) => {
router.get(
{
path: `${INTERNAL_BASE_ACTION_API_PATH}/connector_types`,
validate: {
query: connectorTypesQuerySchemaV1,
},
},
router.handleLegacyErrors(
verifyAccessAndContext(licenseState, async function (context, req, res) {
const actionsClient = (await context.actions).getActionsClient();
// Assert versioned inputs
const query: ConnectorTypesRequestQueryV1 = req.query;
const connectorTypes = await actionsClient.listTypes({
featureId: query?.feature_id,
includeSystemActionTypes: true,
});
const responseBody: ConnectorTypesResponseV1[] =
transformListTypesResponseV1(connectorTypes);
return res.ok({ body: responseBody });
})
)
);
};

View file

@ -24,6 +24,10 @@ beforeEach(() => {
});
describe('executeActionRoute', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('executes an action with proper parameters', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
@ -167,4 +171,35 @@ describe('executeActionRoute', () => {
expect(verifyAccessAndContext).toHaveBeenCalledWith(licenseState, expect.any(Function));
});
it('returns a bad request for system connectors', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
const actionsClient = actionsClientMock.create();
actionsClient.isSystemAction.mockReturnValue(true);
const [context, req, res] = mockHandlerArguments(
{ actionsClient },
{
body: {
params: {},
},
params: {
id: 'system-connector-.test-connector',
},
},
['ok']
);
executeActionRoute(router, licenseState);
const [_, handler] = router.post.mock.calls[0];
await handler(context, req, res);
expect(actionsClient.execute).not.toHaveBeenCalled();
expect(res.ok).not.toHaveBeenCalled();
expect(res.badRequest).toHaveBeenCalled();
});
});

View file

@ -49,12 +49,18 @@ export const executeActionRoute = (
const actionsClient = (await context.actions).getActionsClient();
const { params } = req.body;
const { id } = req.params;
if (actionsClient.isSystemAction(id)) {
return res.badRequest({ body: 'Execution of system action is not allowed' });
}
const body: ActionTypeExecutorResult<unknown> = await actionsClient.execute({
params,
actionId: id,
source: asHttpRequestExecutionSource(req),
relatedSavedObjects: [],
});
return body
? res.ok({
body: rewriteBodyRes(body),

View file

@ -8,7 +8,9 @@
import { IRouter } from '@kbn/core/server';
import { UsageCounter } from '@kbn/usage-collection-plugin/server';
import { getAllConnectorsRoute } from './connector/get_all';
import { getAllConnectorsIncludingSystemRoute } from './connector/get_all_system';
import { listTypesRoute } from './connector/list_types';
import { listTypesWithSystemRoute } from './connector/list_types_system';
import { ILicenseState } from '../lib';
import { ActionsRequestHandlerContext } from '../types';
import { createActionRoute } from './create';
@ -45,4 +47,6 @@ export function defineRoutes(opts: RouteOptions) {
getGlobalExecutionKPIRoute(router, licenseState);
getOAuthAccessToken(router, licenseState, actionsConfigUtils);
getAllConnectorsIncludingSystemRoute(router, licenseState);
listTypesWithSystemRoute(router, licenseState);
}

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { Logger, SavedObjectsClientContract } from '@kbn/core/server';
import { FilterStateStore } from '@kbn/es-query';
import { RRuleParams } from './rrule_type';
export enum MaintenanceWindowStatus {
@ -13,14 +14,6 @@ export enum MaintenanceWindowStatus {
Finished = 'finished',
Archived = 'archived',
}
export const filterStateStore = {
APP_STATE: 'appState',
GLOBAL_STATE: 'globalState',
} as const;
export type FilterStateStore = typeof filterStateStore[keyof typeof filterStateStore];
export interface MaintenanceWindowModificationMetadata {
createdBy: string | null;
updatedBy: string | null;

View file

@ -5,10 +5,5 @@
* 2.0.
*/
export { filterStateStore } from './constants/latest';
export type { FilterStateStore } from './constants/latest';
export { alertsFilterQuerySchema } from './schemas/latest';
export { filterStateStore as filterStateStoreV1 } from './constants/v1';
export type { FilterStateStore as FilterStateStoreV1 } from './constants/v1';
export { alertsFilterQuerySchema as alertsFilterQuerySchemaV1 } from './schemas/v1';

View file

@ -6,7 +6,7 @@
*/
import { schema } from '@kbn/config-schema';
import { filterStateStore } from '..';
import { FilterStateStore } from '@kbn/es-query';
export const alertsFilterQuerySchema = schema.object({
kql: schema.string(),
@ -17,8 +17,8 @@ export const alertsFilterQuerySchema = schema.object({
$state: schema.maybe(
schema.object({
store: schema.oneOf([
schema.literal(filterStateStore.APP_STATE),
schema.literal(filterStateStore.GLOBAL_STATE),
schema.literal(FilterStateStore.APP_STATE),
schema.literal(FilterStateStore.GLOBAL_STATE),
]),
})
),

View file

@ -20,7 +20,7 @@ export const ruleSnoozeScheduleSchema = schema.object({
});
const ruleActionSchema = schema.object({
group: schema.string(),
group: schema.maybe(schema.string()),
id: schema.string(),
params: schema.recordOf(schema.string(), schema.any(), { defaultValue: {} }),
uuid: schema.maybe(schema.string()),

View file

@ -46,7 +46,7 @@ export const actionAlertsFilterSchema = schema.object({
export const actionSchema = schema.object({
uuid: schema.maybe(schema.string()),
group: schema.string(),
group: schema.maybe(schema.string()),
id: schema.string(),
actionTypeId: schema.maybe(schema.string()),
params: schema.recordOf(schema.string(), schema.maybe(schema.any()), { defaultValue: {} }),

View file

@ -43,11 +43,6 @@ export const ruleExecutionStatusWarningReason = {
MAX_QUEUED_ACTIONS: 'maxQueuedActions',
} as const;
export const filterStateStore = {
APP_STATE: 'appState',
GLOBAL_STATE: 'globalState',
} as const;
export type RuleNotifyWhen = typeof ruleNotifyWhen[keyof typeof ruleNotifyWhen];
export type RuleLastRunOutcomeValues =
typeof ruleLastRunOutcomeValues[keyof typeof ruleLastRunOutcomeValues];
@ -57,4 +52,3 @@ export type RuleExecutionStatusErrorReason =
typeof ruleExecutionStatusErrorReason[keyof typeof ruleExecutionStatusErrorReason];
export type RuleExecutionStatusWarningReason =
typeof ruleExecutionStatusWarningReason[keyof typeof ruleExecutionStatusWarningReason];
export type FilterStateStore = typeof filterStateStore[keyof typeof filterStateStore];

View file

@ -11,7 +11,6 @@ export {
ruleExecutionStatusValues,
ruleExecutionStatusErrorReason,
ruleExecutionStatusWarningReason,
filterStateStore,
} from './constants/latest';
export type {
@ -20,7 +19,6 @@ export type {
RuleExecutionStatusValues,
RuleExecutionStatusErrorReason,
RuleExecutionStatusWarningReason,
FilterStateStore,
} from './constants/latest';
export {
@ -29,7 +27,6 @@ export {
ruleExecutionStatusValues as ruleExecutionStatusValuesV1,
ruleExecutionStatusErrorReason as ruleExecutionStatusErrorReasonV1,
ruleExecutionStatusWarningReason as ruleExecutionStatusWarningReasonV1,
filterStateStore as filterStateStoreV1,
} from './constants/v1';
export type {
@ -38,5 +35,4 @@ export type {
RuleExecutionStatusValues as RuleExecutionStatusValuesV1,
RuleExecutionStatusErrorReason as RuleExecutionStatusErrorReasonV1,
RuleExecutionStatusWarningReason as RuleExecutionStatusWarningReasonV1,
FilterStateStore as FilterStateStoreV1,
} from './constants/v1';

View file

@ -66,12 +66,13 @@ const actionAlertsFilterSchema = schema.object({
const actionSchema = schema.object({
uuid: schema.maybe(schema.string()),
group: schema.string(),
group: schema.maybe(schema.string()),
id: schema.string(),
connector_type_id: schema.string(),
params: actionParamsSchema,
frequency: schema.maybe(actionFrequencySchema),
alerts_filter: schema.maybe(actionAlertsFilterSchema),
use_alert_data_for_template: schema.maybe(schema.boolean()),
});
export const ruleExecutionStatusSchema = schema.object({

View file

@ -122,6 +122,16 @@ export interface RuleAction {
useAlertDataForTemplate?: boolean;
}
export interface RuleSystemAction {
uuid?: string;
id: string;
actionTypeId: string;
params: RuleActionParams;
}
export type RuleActionKey = keyof RuleAction;
export type RuleSystemActionKey = keyof RuleSystemAction;
export interface RuleLastRun {
outcome: RuleLastRunOutcomes;
outcomeOrder?: number;
@ -155,6 +165,7 @@ export interface Rule<Params extends RuleTypeParams = never> {
consumer: string;
schedule: IntervalSchedule;
actions: RuleAction[];
systemActions?: RuleSystemAction[];
params: Params;
mapped_params?: MappedParams;
scheduledTaskId?: string | null;

View file

@ -6,7 +6,7 @@
*/
import { schema } from '@kbn/config-schema';
import { filterStateStore } from '../constants';
import { FilterStateStore } from '@kbn/es-query';
export const alertsFilterQuerySchema = schema.object({
kql: schema.string(),
@ -17,8 +17,8 @@ export const alertsFilterQuerySchema = schema.object({
$state: schema.maybe(
schema.object({
store: schema.oneOf([
schema.literal(filterStateStore.APP_STATE),
schema.literal(filterStateStore.GLOBAL_STATE),
schema.literal(FilterStateStore.APP_STATE),
schema.literal(FilterStateStore.GLOBAL_STATE),
]),
})
),

View file

@ -17,8 +17,3 @@ export const maintenanceWindowCategoryIdTypes = {
SECURITY_SOLUTION: 'securitySolution',
MANAGEMENT: 'management',
} as const;
export const filterStateStore = {
APP_STATE: 'appState',
GLOBAL_STATE: 'globalState',
} as const;

View file

@ -16,6 +16,7 @@ import {
} from '../../../../../common';
import { getMockMaintenanceWindow } from '../../../../data/maintenance_window/test_helpers';
import type { MaintenanceWindow } from '../../types';
import { FilterStateStore } from '@kbn/es-query';
const savedObjectsClient = savedObjectsClientMock.create();
@ -168,7 +169,7 @@ describe('MaintenanceWindowClient - create', () => {
type: 'phrase',
},
$state: {
store: 'appState',
store: FilterStateStore.APP_STATE,
},
query: {
match_phrase: {
@ -281,7 +282,7 @@ describe('MaintenanceWindowClient - create', () => {
type: 'phrase',
},
$state: {
store: 'appState',
store: FilterStateStore.APP_STATE,
},
query: {
match_phrase: {

View file

@ -17,6 +17,7 @@ import {
} from '../../../../../common';
import { getMockMaintenanceWindow } from '../../../../data/maintenance_window/test_helpers';
import type { MaintenanceWindow } from '../../types';
import { FilterStateStore } from '@kbn/es-query';
const savedObjectsClient = savedObjectsClientMock.create();
@ -260,7 +261,7 @@ describe('MaintenanceWindowClient - update', () => {
type: 'phrase',
},
$state: {
store: 'appState',
store: FilterStateStore.APP_STATE,
},
query: {
match_phrase: {
@ -407,7 +408,7 @@ describe('MaintenanceWindowClient - update', () => {
type: 'phrase',
},
$state: {
store: 'appState',
store: FilterStateStore.APP_STATE,
},
query: {
match_phrase: {

View file

@ -26,7 +26,15 @@ export const transformMaintenanceWindowToMaintenanceWindowAttributes = (
? { categoryIds: maintenanceWindow.categoryIds }
: {}),
...(maintenanceWindow.scopedQuery !== undefined
? { scopedQuery: maintenanceWindow.scopedQuery }
? maintenanceWindow?.scopedQuery == null
? { scopedQuery: maintenanceWindow?.scopedQuery }
: {
scopedQuery: {
filters: maintenanceWindow?.scopedQuery?.filters ?? [],
kql: maintenanceWindow?.scopedQuery?.kql ?? '',
dsl: maintenanceWindow?.scopedQuery?.dsl ?? '',
},
}
: {}),
};
};

View file

@ -42,8 +42,3 @@ export const ruleExecutionStatusWarningReason = {
MAX_ALERTS: 'maxAlerts',
MAX_QUEUED_ACTIONS: 'maxQueuedActions',
} as const;
export const filterStateStore = {
APP_STATE: 'appState',
GLOBAL_STATE: 'globalState',
} as const;

View file

@ -27,6 +27,7 @@ import { fromKueryExpression, nodeTypes } from '@kbn/es-query';
import { RecoveredActionGroup } from '../../../../../common';
import { DefaultRuleAggregationResult } from '../../../../routes/rule/apis/aggregate/types';
import { defaultRuleAggregationFactory } from '.';
import { ConnectorAdapterRegistry } from '../../../../connector_adapters/connector_adapter_registry';
import { RULE_SAVED_OBJECT_TYPE } from '../../../../saved_objects';
const taskManager = taskManagerMock.createStart();
@ -58,11 +59,13 @@ const rulesClientParams: jest.Mocked<ConstructorOptions> = {
kibanaVersion,
isAuthenticationTypeAPIKey: jest.fn(),
getAuthenticationAPIKey: jest.fn(),
connectorAdapterRegistry: new ConnectorAdapterRegistry(),
getAlertIndicesAlias: jest.fn(),
alertsService: null,
maxScheduledPerMinute: 1000,
internalSavedObjectsRepository,
uiSettings: uiSettingsServiceMock.createStartContract(),
isSystemAction: jest.fn(),
};
beforeEach(() => {

View file

@ -18,6 +18,7 @@ import { actionsAuthorizationMock } from '@kbn/actions-plugin/server/mocks';
import { ActionsAuthorization } from '@kbn/actions-plugin/server';
import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks';
import { loggerMock } from '@kbn/logging-mocks';
import { ActionsClient } from '@kbn/actions-plugin/server';
import { ruleTypeRegistryMock } from '../../../../rule_type_registry.mock';
import { alertingAuthorizationMock } from '../../../../authorization/alerting_authorization.mock';
import { RecoveredActionGroup } from '../../../../../common';
@ -28,12 +29,17 @@ import {
enabledRuleForBulkOps1,
enabledRuleForBulkOps2,
enabledRuleForBulkOps3,
returnedRuleForBulkDelete1,
returnedRuleForBulkDelete2,
returnedRuleForBulkDelete3,
returnedRuleForBulkOps1,
returnedRuleForBulkOps2,
returnedRuleForBulkOps3,
siemRuleForBulkOps1,
enabledRuleForBulkOpsWithActions1,
enabledRuleForBulkOpsWithActions2,
returnedRuleForBulkEnableWithActions1,
returnedRuleForBulkEnableWithActions2,
} from '../../../../rules_client/tests/test_helpers';
import { migrateLegacyActions } from '../../../../rules_client/lib';
import { ConnectorAdapterRegistry } from '../../../../connector_adapters/connector_adapter_registry';
import { RULE_SAVED_OBJECT_TYPE } from '../../../../saved_objects';
jest.mock('../../../../rules_client/lib/siem_legacy_actions/migrate_legacy_actions', () => {
@ -84,6 +90,8 @@ const rulesClientParams: jest.Mocked<ConstructorOptions> = {
minimumScheduleInterval: { value: '1m', enforce: false },
isAuthenticationTypeAPIKey: jest.fn(),
getAuthenticationAPIKey: jest.fn(),
connectorAdapterRegistry: new ConnectorAdapterRegistry(),
isSystemAction: jest.fn(),
getAlertIndicesAlias: jest.fn(),
alertsService: null,
uiSettings: uiSettingsServiceMock.createStartContract(),
@ -109,11 +117,12 @@ setGlobalDate();
describe('bulkDelete', () => {
let rulesClient: RulesClient;
let actionsClient: jest.Mocked<ActionsClient>;
const mockCreatePointInTimeFinderAsInternalUser = (
response = {
saved_objects: [enabledRuleForBulkOps1, enabledRuleForBulkOps2, enabledRuleForBulkOps3],
}
} as unknown
) => {
encryptedSavedObjects.createPointInTimeFinderDecryptedAsInternalUser = jest
.fn()
@ -165,6 +174,48 @@ describe('bulkDelete', () => {
},
validLegacyConsumers: [],
});
actionsClient = (await rulesClientParams.getActionsClient()) as jest.Mocked<ActionsClient>;
actionsClient.isSystemAction.mockImplementation((id: string) => id === 'system_action:id');
rulesClientParams.getActionsClient.mockResolvedValue(actionsClient);
});
test('should successfully delete two rule and return right actions', async () => {
mockCreatePointInTimeFinderAsInternalUser({
saved_objects: [enabledRuleForBulkOpsWithActions1, enabledRuleForBulkOpsWithActions2],
});
unsecuredSavedObjectsClient.bulkDelete.mockResolvedValue({
statuses: [
{ id: 'id1', type: 'alert', success: true },
{ id: 'id2', type: 'alert', success: true },
],
});
const result = await rulesClient.bulkDeleteRules({ filter: 'fake_filter' });
expect(unsecuredSavedObjectsClient.bulkDelete).toHaveBeenCalledTimes(1);
expect(unsecuredSavedObjectsClient.bulkDelete).toHaveBeenCalledWith(
[enabledRuleForBulkOps1, enabledRuleForBulkOps2].map(({ id }) => ({
id,
type: 'alert',
})),
undefined
);
expect(taskManager.bulkRemove).toHaveBeenCalledTimes(1);
expect(taskManager.bulkRemove).toHaveBeenCalledWith(['id1', 'id2']);
expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1);
expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith(
{ apiKeys: ['MTIzOmFiYw==', 'MzIxOmFiYw=='] },
expect.anything(),
expect.anything()
);
expect(result).toStrictEqual({
rules: [returnedRuleForBulkEnableWithActions1, returnedRuleForBulkEnableWithActions2],
errors: [],
total: 2,
taskIdsFailedToBeDeleted: [],
});
});
test('should try to delete rules, two successful and one with 500 error', async () => {
@ -196,7 +247,7 @@ describe('bulkDelete', () => {
expect.anything()
);
expect(result).toStrictEqual({
rules: [returnedRuleForBulkDelete1, returnedRuleForBulkDelete3],
rules: [returnedRuleForBulkOps1, returnedRuleForBulkOps3],
errors: [{ message: 'UPS', rule: { id: 'id2', name: 'fakeName' }, status: 500 }],
total: 2,
taskIdsFailedToBeDeleted: [],
@ -260,7 +311,7 @@ describe('bulkDelete', () => {
expect.anything()
);
expect(result).toStrictEqual({
rules: [returnedRuleForBulkDelete1],
rules: [returnedRuleForBulkOps1],
errors: [{ message: 'UPS', rule: { id: 'id2', name: 'fakeName' }, status: 409 }],
total: 2,
taskIdsFailedToBeDeleted: [],
@ -318,7 +369,7 @@ describe('bulkDelete', () => {
expect.anything()
);
expect(result).toStrictEqual({
rules: [returnedRuleForBulkDelete1, returnedRuleForBulkDelete2],
rules: [returnedRuleForBulkOps1, returnedRuleForBulkOps2],
errors: [],
total: 2,
taskIdsFailedToBeDeleted: [],

View file

@ -50,6 +50,7 @@ export const bulkDeleteRules = async <Params extends RuleParams>(
}
const { ids, filter } = options;
const actionsClient = await context.getActionsClient();
const kueryNodeFilter = ids ? convertRuleIdsToKueryNode(ids) : buildKueryNodeFilter(filter);
const authorizationFilter = await getAuthorizationFilter(context, { action: 'DELETE' });
@ -96,13 +97,17 @@ export const bulkDeleteRules = async <Params extends RuleParams>(
// fix the type cast from SavedObjectsBulkUpdateObject to SavedObjectsBulkUpdateObject
// when we are doing the bulk delete and this should fix itself
const ruleType = context.ruleTypeRegistry.get(attributes.alertTypeId!);
const ruleDomain = transformRuleAttributesToRuleDomain<Params>(attributes as RuleAttributes, {
id,
logger: context.logger,
ruleType,
references,
omitGeneratedValues: false,
});
const ruleDomain = transformRuleAttributesToRuleDomain<Params>(
attributes as RuleAttributes,
{
id,
logger: context.logger,
ruleType,
references,
omitGeneratedValues: false,
},
(connectorId: string) => actionsClient.isSystemAction(connectorId)
);
try {
ruleDomainSchema.validate(ruleDomain);

View file

@ -31,14 +31,20 @@ import {
savedObjectWith500Error,
disabledRuleForBulkDisable1,
disabledRuleForBulkDisable2,
returnedRuleForBulkDisableWithActions1,
returnedRuleForBulkDisableWithActions2,
enabledRuleForBulkOps1,
enabledRuleForBulkOps2,
disabledRuleForBulkOpsWithActions1,
disabledRuleForBulkOpsWithActions2,
returnedRuleForBulkDisable1,
returnedRuleForBulkDisable2,
siemRuleForBulkOps1,
siemRuleForBulkOps2,
} from '../../../../rules_client/tests/test_helpers';
import { migrateLegacyActions } from '../../../../rules_client/lib';
import { ConnectorAdapterRegistry } from '../../../../connector_adapters/connector_adapter_registry';
import { ActionsClient } from '@kbn/actions-plugin/server';
import { RULE_SAVED_OBJECT_TYPE } from '../../../../saved_objects';
jest.mock('../../../../rules_client/lib/siem_legacy_actions/migrate_legacy_actions', () => {
@ -96,6 +102,8 @@ const rulesClientParams: jest.Mocked<ConstructorOptions> = {
minimumScheduleInterval: { value: '1m', enforce: false },
isAuthenticationTypeAPIKey: jest.fn(),
getAuthenticationAPIKey: jest.fn(),
connectorAdapterRegistry: new ConnectorAdapterRegistry(),
isSystemAction: jest.fn(),
getAlertIndicesAlias: jest.fn(),
alertsService: null,
uiSettings: uiSettingsServiceMock.createStartContract(),
@ -110,6 +118,8 @@ setGlobalDate();
describe('bulkDisableRules', () => {
let rulesClient: RulesClient;
let actionsClient: jest.Mocked<ActionsClient>;
const mockCreatePointInTimeFinderAsInternalUser = (
response = { saved_objects: [enabledRule1, enabledRule2] }
) => {
@ -145,6 +155,9 @@ describe('bulkDisableRules', () => {
beforeEach(async () => {
rulesClient = new RulesClient(rulesClientParams);
actionsClient = (await rulesClientParams.getActionsClient()) as jest.Mocked<ActionsClient>;
rulesClientParams.getActionsClient.mockResolvedValue(actionsClient);
authorization.getFindAuthorizationFilter.mockResolvedValue({
ensureRuleTypeIsAuthorized() {},
});
@ -155,6 +168,7 @@ describe('bulkDisableRules', () => {
resultedActions: [],
resultedReferences: [],
});
actionsClient.isSystemAction.mockImplementation((id: string) => id === 'system_action:id');
});
test('should disable two rule', async () => {
@ -190,6 +204,38 @@ describe('bulkDisableRules', () => {
});
});
test('should disable two rule and return right actions', async () => {
unsecuredSavedObjectsClient.bulkCreate.mockResolvedValue({
saved_objects: [disabledRuleForBulkOpsWithActions1, disabledRuleForBulkOpsWithActions2],
});
const result = await rulesClient.bulkDisableRules({ filter: 'fake_filter' });
expect(unsecuredSavedObjectsClient.bulkCreate).toHaveBeenCalledTimes(1);
expect(unsecuredSavedObjectsClient.bulkCreate).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
id: 'id1',
attributes: expect.objectContaining({
enabled: false,
}),
}),
expect.objectContaining({
id: 'id2',
attributes: expect.objectContaining({
enabled: false,
}),
}),
]),
{ overwrite: true }
);
expect(result).toStrictEqual({
errors: [],
rules: [returnedRuleForBulkDisableWithActions1, returnedRuleForBulkDisableWithActions2],
total: 2,
});
});
test('should call untrack alert if untrack is true', async () => {
unsecuredSavedObjectsClient.bulkCreate.mockResolvedValue({
saved_objects: [disabledRuleForBulkDisable1, disabledRuleForBulkDisable2],
@ -683,25 +729,25 @@ describe('bulkDisableRules', () => {
await rulesClient.bulkDisableRules({ filter: 'fake_filter' });
expect(migrateLegacyActions).toHaveBeenCalledTimes(4);
expect(migrateLegacyActions).toHaveBeenCalledWith(expect.any(Object), {
expect(migrateLegacyActions).toHaveBeenNthCalledWith(1, expect.any(Object), {
attributes: enabledRuleForBulkOps1.attributes,
ruleId: enabledRuleForBulkOps1.id,
actions: [],
references: [],
});
expect(migrateLegacyActions).toHaveBeenCalledWith(expect.any(Object), {
expect(migrateLegacyActions).toHaveBeenNthCalledWith(2, expect.any(Object), {
attributes: enabledRuleForBulkOps2.attributes,
ruleId: enabledRuleForBulkOps2.id,
actions: [],
references: [],
});
expect(migrateLegacyActions).toHaveBeenCalledWith(expect.any(Object), {
expect(migrateLegacyActions).toHaveBeenNthCalledWith(3, expect.any(Object), {
attributes: expect.objectContaining({ consumer: AlertConsumers.SIEM }),
ruleId: siemRuleForBulkOps1.id,
actions: [],
references: [],
});
expect(migrateLegacyActions).toHaveBeenCalledWith(expect.any(Object), {
expect(migrateLegacyActions).toHaveBeenNthCalledWith(4, expect.any(Object), {
attributes: expect.objectContaining({ consumer: AlertConsumers.SIEM }),
ruleId: siemRuleForBulkOps2.id,
actions: [],

View file

@ -51,6 +51,7 @@ export const bulkDisableRules = async <Params extends RuleParams>(
}
const { ids, filter, untrack = false } = options;
const actionsClient = await context.getActionsClient();
const kueryNodeFilter = ids ? convertRuleIdsToKueryNode(ids) : buildKueryNodeFilter(filter);
const authorizationFilter = await getAuthorizationFilter(context, { action: 'DISABLE' });
@ -94,13 +95,17 @@ export const bulkDisableRules = async <Params extends RuleParams>(
// fix the type cast from SavedObjectsBulkUpdateObject to SavedObjectsBulkUpdateObject
// when we are doing the bulk disable and this should fix itself
const ruleType = context.ruleTypeRegistry.get(attributes.alertTypeId!);
const ruleDomain = transformRuleAttributesToRuleDomain<Params>(attributes as RuleAttributes, {
id,
logger: context.logger,
ruleType,
references,
omitGeneratedValues: false,
});
const ruleDomain = transformRuleAttributesToRuleDomain<Params>(
attributes as RuleAttributes,
{
id,
logger: context.logger,
ruleType,
references,
omitGeneratedValues: false,
},
(connectorId: string) => actionsClient.isSystemAction(connectorId)
);
try {
ruleDomainSchema.validate(ruleDomain);

View file

@ -27,7 +27,6 @@ import { ActionsAuthorization, ActionsClient } from '@kbn/actions-plugin/server'
import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks';
import { getBeforeSetup, setGlobalDate } from '../../../../rules_client/tests/lib';
import { bulkMarkApiKeysForInvalidation } from '../../../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation';
import { NormalizedAlertAction } from '../../../../rules_client/types';
import {
enabledRule1,
enabledRule2,
@ -36,6 +35,11 @@ import {
} from '../../../../rules_client/tests/test_helpers';
import { migrateLegacyActions } from '../../../../rules_client/lib';
import { migrateLegacyActionsMock } from '../../../../rules_client/lib/siem_legacy_actions/retrieve_migrated_legacy_actions.mock';
import { ConnectorAdapterRegistry } from '../../../../connector_adapters/connector_adapter_registry';
import { ConnectorAdapter } from '../../../../connector_adapters/types';
import { RuleAttributes } from '../../../../data/rule/types';
import { SavedObject } from '@kbn/core/server';
import { bulkEditOperationsSchema } from './schemas';
import { RULE_SAVED_OBJECT_TYPE } from '../../../../saved_objects';
jest.mock('../../../../rules_client/lib/siem_legacy_actions/migrate_legacy_actions', () => {
@ -104,6 +108,8 @@ const rulesClientParams: jest.Mocked<ConstructorOptions> = {
minimumScheduleInterval: { value: '1m', enforce: false },
isAuthenticationTypeAPIKey: isAuthenticationTypeApiKeyMock,
getAuthenticationAPIKey: getAuthenticationApiKeyMock,
connectorAdapterRegistry: new ConnectorAdapterRegistry(),
isSystemAction: jest.fn(),
getAlertIndicesAlias: jest.fn(),
alertsService: null,
uiSettings: uiSettingsServiceMock.createStartContract(),
@ -245,14 +251,17 @@ describe('bulkEdit()', () => {
return { state: {} };
},
category: 'test',
validLegacyConsumers: [],
producer: 'alerts',
validate: {
params: { validate: (params) => params },
},
validLegacyConsumers: [],
});
(migrateLegacyActions as jest.Mock).mockResolvedValue(migrateLegacyActionsMock);
rulesClientParams.isSystemAction.mockImplementation((id: string) => id === 'system_action-id');
actionsClient.isSystemAction.mockImplementation((id: string) => id === 'system_action-id');
});
describe('tags operations', () => {
@ -537,6 +546,14 @@ describe('bulkEdit()', () => {
});
describe('actions operations', () => {
const connectorAdapter: ConnectorAdapter = {
connectorTypeId: '.test',
ruleActionParamsSchema: schema.object({ foo: schema.string() }),
buildActionParams: jest.fn(),
};
rulesClientParams.connectorAdapterRegistry.register(connectorAdapter);
beforeEach(() => {
mockCreatePointInTimeFinderAsInternalUser({
saved_objects: [existingDecryptedRule],
@ -546,7 +563,7 @@ describe('bulkEdit()', () => {
test('should add uuid to new actions', async () => {
const existingAction = {
frequency: {
notifyWhen: 'onActiveAlert',
notifyWhen: 'onActiveAlert' as const,
summary: false,
throttle: null,
},
@ -555,9 +572,10 @@ describe('bulkEdit()', () => {
params: {},
uuid: '111',
};
const newAction = {
frequency: {
notifyWhen: 'onActiveAlert',
notifyWhen: 'onActiveAlert' as const,
summary: false,
throttle: null,
},
@ -565,9 +583,10 @@ describe('bulkEdit()', () => {
id: '2',
params: {},
};
const newAction2 = {
frequency: {
notifyWhen: 'onActiveAlert',
notifyWhen: 'onActiveAlert' as const,
summary: false,
throttle: null,
},
@ -586,10 +605,12 @@ describe('bulkEdit()', () => {
{
...existingAction,
actionRef: 'action_0',
actionTypeId: 'test-0',
},
{
...newAction,
actionRef: 'action_1',
actionTypeId: 'test-1',
uuid: '222',
},
],
@ -616,7 +637,7 @@ describe('bulkEdit()', () => {
{
field: 'actions',
operation: 'add',
value: [existingAction, newAction, newAction2] as NormalizedAlertAction[],
value: [existingAction, newAction, newAction2],
},
],
});
@ -678,7 +699,11 @@ describe('bulkEdit()', () => {
...existingRule.attributes.executionStatus,
lastExecutionDate: new Date(existingRule.attributes.executionStatus.lastExecutionDate),
},
actions: [existingAction, { ...newAction, uuid: '222' }],
actions: [
{ ...existingAction, actionTypeId: 'test-0' },
{ ...newAction, uuid: '222', actionTypeId: 'test-1' },
],
systemActions: [],
id: existingRule.id,
snoozeSchedule: [],
});
@ -743,7 +768,6 @@ describe('bulkEdit()', () => {
async executor() {
return { state: {} };
},
category: 'test',
producer: 'alerts',
validate: {
params: { validate: (params) => params },
@ -753,11 +777,13 @@ describe('bulkEdit()', () => {
mappings: { fieldMap: { field: { type: 'keyword', required: false } } },
shouldWrite: true,
},
category: 'test',
validLegacyConsumers: [],
});
const existingAction = {
frequency: {
notifyWhen: 'onActiveAlert',
notifyWhen: 'onActiveAlert' as const,
summary: false,
throttle: null,
},
@ -780,7 +806,7 @@ describe('bulkEdit()', () => {
};
const newAction = {
frequency: {
notifyWhen: 'onActiveAlert',
notifyWhen: 'onActiveAlert' as const,
summary: false,
throttle: null,
},
@ -834,7 +860,7 @@ describe('bulkEdit()', () => {
{
field: 'actions',
operation: 'add',
value: [existingAction, newAction] as NormalizedAlertAction[],
value: [existingAction, newAction],
},
],
});
@ -910,8 +936,651 @@ describe('bulkEdit()', () => {
],
id: existingRule.id,
snoozeSchedule: [],
systemActions: [],
});
});
test('should add system and default actions', async () => {
const defaultAction = {
frequency: {
notifyWhen: 'onActiveAlert' as const,
summary: false,
throttle: null,
},
group: 'default',
id: '1',
params: {},
};
const systemAction = {
id: 'system_action-id',
params: {},
};
unsecuredSavedObjectsClient.bulkCreate.mockResolvedValue({
saved_objects: [
{
...existingRule,
attributes: {
...existingRule.attributes,
actions: [
{
frequency: {
notifyWhen: 'onActiveAlert' as const,
summary: false,
throttle: null,
},
group: 'default',
params: {},
actionRef: 'action_0',
actionTypeId: 'test-1',
uuid: '222',
},
{
params: {},
actionRef: 'system_action:system_action-id',
actionTypeId: 'test-2',
uuid: '222',
},
],
},
references: [
{
name: 'action_0',
type: 'action',
id: '1',
},
],
},
],
});
actionsClient.getBulk.mockResolvedValue([
{
id: '1',
actionTypeId: 'test-1',
config: {},
isMissingSecrets: false,
name: 'test default connector',
isPreconfigured: false,
isDeprecated: false,
isSystemAction: false,
},
{
id: 'system_action-id',
actionTypeId: 'test-2',
config: {},
isMissingSecrets: false,
name: 'system action connector',
isPreconfigured: false,
isDeprecated: false,
isSystemAction: true,
},
]);
const result = await rulesClient.bulkEdit({
filter: '',
operations: [
{
field: 'actions',
operation: 'add',
value: [defaultAction, systemAction],
},
],
});
expect(unsecuredSavedObjectsClient.bulkCreate).toHaveBeenCalledWith(
[
{
...existingRule,
attributes: {
...existingRule.attributes,
actions: [
{
actionRef: 'action_0',
actionTypeId: 'test-1',
frequency: { notifyWhen: 'onActiveAlert', summary: false, throttle: null },
group: 'default',
params: {},
uuid: '103',
},
{
actionRef: 'system_action:system_action-id',
actionTypeId: 'test-2',
params: {},
uuid: '104',
},
],
apiKey: null,
apiKeyOwner: null,
apiKeyCreatedByUser: null,
meta: { versionApiKeyLastmodified: 'v8.2.0' },
name: 'my rule name',
enabled: false,
updatedAt: '2019-02-12T21:01:22.479Z',
updatedBy: 'elastic',
tags: ['foo'],
revision: 1,
},
references: [{ id: '1', name: 'action_0', type: 'action' }],
},
],
{ overwrite: true }
);
expect(result.rules[0]).toEqual({
...omit(existingRule.attributes, 'legacyId'),
createdAt: new Date(existingRule.attributes.createdAt),
updatedAt: new Date(existingRule.attributes.updatedAt),
executionStatus: {
...existingRule.attributes.executionStatus,
lastExecutionDate: new Date(existingRule.attributes.executionStatus.lastExecutionDate),
},
actions: [{ ...defaultAction, actionTypeId: 'test-1', uuid: '222' }],
systemActions: [{ ...systemAction, actionTypeId: 'test-2', uuid: '222' }],
id: existingRule.id,
snoozeSchedule: [],
});
});
test('should construct the refs correctly and persist the actions correctly', async () => {
const defaultAction = {
frequency: {
notifyWhen: 'onActiveAlert' as const,
summary: false,
throttle: null,
},
group: 'default',
id: '1',
params: {},
};
const systemAction = {
id: 'system_action-id',
params: {},
};
unsecuredSavedObjectsClient.bulkCreate.mockResolvedValue({
saved_objects: [
{
...existingRule,
attributes: {
...existingRule.attributes,
actions: [
{
frequency: {
notifyWhen: 'onActiveAlert' as const,
summary: false,
throttle: null,
},
group: 'default',
params: {},
actionRef: 'action_0',
actionTypeId: 'test-1',
uuid: '222',
},
{
params: {},
actionRef: 'system_action:system_action-id',
actionTypeId: 'test-2',
uuid: '222',
},
],
},
references: [
{
name: 'action_0',
type: 'action',
id: '1',
},
],
},
],
});
actionsClient.getBulk.mockResolvedValue([
{
id: '1',
actionTypeId: 'test-1',
config: {},
isMissingSecrets: false,
name: 'test default connector',
isPreconfigured: false,
isDeprecated: false,
isSystemAction: false,
},
{
id: 'system_action-id',
actionTypeId: 'test-2',
config: {},
isMissingSecrets: false,
name: 'system action connector',
isPreconfigured: false,
isDeprecated: false,
isSystemAction: true,
},
]);
await rulesClient.bulkEdit({
filter: '',
operations: [
{
field: 'actions',
operation: 'add',
value: [defaultAction, systemAction],
},
],
});
const rule = unsecuredSavedObjectsClient.bulkCreate.mock.calls[0][0] as Array<
SavedObject<RuleAttributes>
>;
expect(rule[0].attributes.actions).toEqual([
{
actionRef: 'action_0',
actionTypeId: 'test-1',
frequency: { notifyWhen: 'onActiveAlert', summary: false, throttle: null },
group: 'default',
params: {},
uuid: '105',
},
{
actionRef: 'system_action:system_action-id',
actionTypeId: 'test-2',
params: {},
uuid: '106',
},
]);
});
test('should transforms the actions correctly', async () => {
const defaultAction = {
frequency: {
notifyWhen: 'onActiveAlert' as const,
summary: false,
throttle: null,
},
group: 'default',
id: '1',
params: {},
};
const systemAction = {
id: 'system_action-id',
params: {},
};
unsecuredSavedObjectsClient.bulkCreate.mockResolvedValue({
saved_objects: [
{
...existingRule,
attributes: {
...existingRule.attributes,
actions: [
{
frequency: {
notifyWhen: 'onActiveAlert' as const,
summary: false,
throttle: null,
},
group: 'default',
params: {},
actionRef: 'action_0',
actionTypeId: 'test-1',
uuid: '222',
},
{
params: {},
actionRef: 'system_action:system_action-id',
actionTypeId: 'test-2',
uuid: '222',
},
],
},
references: [
{
name: 'action_0',
type: 'action',
id: '1',
},
],
},
],
});
actionsClient.getBulk.mockResolvedValue([
{
id: '1',
actionTypeId: 'test-1',
config: {},
isMissingSecrets: false,
name: 'test default connector',
isPreconfigured: false,
isDeprecated: false,
isSystemAction: false,
},
{
id: 'system_action-id',
actionTypeId: 'test-2',
config: {},
isMissingSecrets: false,
name: 'system action connector',
isPreconfigured: false,
isDeprecated: false,
isSystemAction: true,
},
]);
const result = await rulesClient.bulkEdit({
filter: '',
operations: [
{
field: 'actions',
operation: 'add',
value: [defaultAction, systemAction],
},
],
});
expect(result.rules[0].actions).toEqual([
{ ...defaultAction, actionTypeId: 'test-1', uuid: '222' },
]);
expect(result.rules[0].systemActions).toEqual([
{ ...systemAction, actionTypeId: 'test-2', uuid: '222' },
]);
});
it('should return an error if the action does not have the right attributes', async () => {
const action = {
id: 'system_action-id',
uuid: '123',
params: {},
};
actionsClient.isSystemAction.mockReturnValue(false);
actionsClient.getBulk.mockResolvedValue([
{
id: 'system_action-id',
actionTypeId: 'test-2',
config: {},
isMissingSecrets: false,
name: 'system action connector',
isPreconfigured: false,
isDeprecated: false,
isSystemAction: true,
},
]);
const result = await rulesClient.bulkEdit({
filter: '',
operations: [
{
field: 'actions',
operation: 'add',
value: [action],
},
],
});
expect(result).toMatchInlineSnapshot(`
Object {
"errors": Array [
Object {
"message": "Error validating bulk edit rules operations - [0.group]: expected value of type [string] but got [undefined]",
"rule": Object {
"id": "1",
"name": "my rule name",
},
},
],
"rules": Array [],
"skipped": Array [],
"total": 1,
}
`);
});
it('should throw an error if the system action contains the group', async () => {
const action = {
id: 'system_action-id',
uuid: '123',
params: {},
group: 'default',
};
actionsClient.isSystemAction.mockReturnValue(true);
actionsClient.getBulk.mockResolvedValue([
{
id: 'system_action-id',
actionTypeId: 'test-2',
config: {},
isMissingSecrets: false,
name: 'system action connector',
isPreconfigured: false,
isDeprecated: false,
isSystemAction: true,
},
]);
const res = await rulesClient.bulkEdit({
filter: '',
operations: [
{
field: 'actions',
operation: 'add',
value: [action],
},
],
});
expect(res).toEqual({
errors: [
{
message:
'Error validating bulk edit rules operations - [0.group]: definition for this key is missing',
rule: {
id: '1',
name: 'my rule name',
},
},
],
rules: [],
skipped: [],
total: 1,
});
});
it('should throw an error if the system action contains the frequency', async () => {
const action = {
id: 'system_action-id',
uuid: '123',
params: {},
frequency: {
notifyWhen: 'onActiveAlert' as const,
summary: false,
throttle: null,
},
};
actionsClient.isSystemAction.mockReturnValue(true);
actionsClient.getBulk.mockResolvedValue([
{
id: 'system_action-id',
actionTypeId: 'test-2',
config: {},
isMissingSecrets: false,
name: 'system action connector',
isPreconfigured: false,
isDeprecated: false,
isSystemAction: true,
},
]);
const res = await rulesClient.bulkEdit({
filter: '',
operations: [
{
field: 'actions',
operation: 'add',
value: [action],
},
],
});
expect(res).toEqual({
errors: [
{
message:
'Error validating bulk edit rules operations - [0.frequency]: definition for this key is missing',
rule: {
id: '1',
name: 'my rule name',
},
},
],
rules: [],
skipped: [],
total: 1,
});
});
it('should throw an error if the system action contains the alertsFilter', async () => {
const action = {
id: 'system_action-id',
uuid: '123',
params: {},
alertsFilter: {
query: { kql: 'test:1', filters: [] },
},
};
actionsClient.isSystemAction.mockReturnValue(true);
actionsClient.getBulk.mockResolvedValue([
{
id: 'system_action-id',
actionTypeId: 'test-2',
config: {},
isMissingSecrets: false,
name: 'system action connector',
isPreconfigured: false,
isDeprecated: false,
isSystemAction: true,
},
]);
const res = await rulesClient.bulkEdit({
filter: '',
operations: [
{
field: 'actions',
operation: 'add',
value: [action],
},
],
});
expect(res).toEqual({
errors: [
{
message:
'Error validating bulk edit rules operations - [0.alertsFilter]: definition for this key is missing',
rule: {
id: '1',
name: 'my rule name',
},
},
],
rules: [],
skipped: [],
total: 1,
});
});
it('should throw an error if the same system action is used twice', async () => {
const action = {
id: 'system_action-id',
uuid: '123',
params: {},
};
actionsClient.isSystemAction.mockReturnValue(true);
actionsClient.getBulk.mockResolvedValue([
{
id: 'system_action-id',
actionTypeId: 'test-2',
config: {},
isMissingSecrets: false,
name: 'system action connector',
isPreconfigured: false,
isDeprecated: false,
isSystemAction: true,
},
]);
const res = await rulesClient.bulkEdit({
filter: '',
operations: [
{
field: 'actions',
operation: 'add',
value: [action, action],
},
],
});
expect(res).toEqual({
errors: [
{
message: 'Cannot use the same system action twice',
rule: {
id: '1',
name: 'my rule name',
},
},
],
rules: [],
skipped: [],
total: 1,
});
});
it('should throw an error if the default action does not contain the group', async () => {
const action = {
id: '1',
params: {},
};
actionsClient.isSystemAction.mockReturnValue(false);
await expect(
rulesClient.bulkEdit({
filter: '',
operations: [
{
field: 'actions',
operation: 'add',
value: [action],
},
],
})
).resolves.toMatchInlineSnapshot(`
Object {
"errors": Array [
Object {
"message": "Error validating bulk edit rules operations - [0.group]: expected value of type [string] but got [undefined]",
"rule": Object {
"id": "1",
"name": "my rule name",
},
},
],
"rules": Array [],
"skipped": Array [],
"total": 1,
}
`);
});
});
describe('index pattern operations', () => {
@ -969,7 +1638,13 @@ describe('bulkEdit()', () => {
const result = await rulesClient.bulkEdit({
filter: '',
operations: [],
operations: [
{
field: 'tags',
operation: 'add',
value: ['test-tag'],
},
],
paramsModifier,
});
@ -1038,7 +1713,13 @@ describe('bulkEdit()', () => {
const result = await rulesClient.bulkEdit({
filter: '',
operations: [],
operations: [
{
field: 'tags',
operation: 'add',
value: ['test-tag'],
},
],
paramsModifier,
});
@ -1063,6 +1744,9 @@ describe('bulkEdit()', () => {
});
test('should skip operation when params modifiers does not modify index pattern array', async () => {
const originalValidate = bulkEditOperationsSchema.validate;
bulkEditOperationsSchema.validate = jest.fn();
paramsModifier.mockResolvedValue({
modifiedParams: {
index: ['test-1', 'test-2'],
@ -1081,6 +1765,8 @@ describe('bulkEdit()', () => {
expect(unsecuredSavedObjectsClient.bulkCreate).toHaveBeenCalledTimes(0);
expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(0);
bulkEditOperationsSchema.validate = originalValidate;
});
});
@ -2325,8 +3011,8 @@ describe('bulkEdit()', () => {
async executor() {
return { state: {} };
},
category: 'test',
producer: 'alerts',
category: 'test',
validLegacyConsumers: [],
});
@ -2371,8 +3057,8 @@ describe('bulkEdit()', () => {
async executor() {
return { state: {} };
},
category: 'test',
producer: 'alerts',
category: 'test',
validLegacyConsumers: [],
});
@ -2411,7 +3097,13 @@ describe('bulkEdit()', () => {
const result = await rulesClient.bulkEdit({
filter: '',
operations: [],
operations: [
{
field: 'tags',
operation: 'add',
value: ['test-1'],
},
],
paramsModifier: async (params) => {
params.index = ['test-index-*'];
@ -2546,6 +3238,49 @@ describe('bulkEdit()', () => {
expect(validateScheduleLimit).toHaveBeenCalledTimes(1);
});
test('should not validate scheduling on system actions', async () => {
mockCreatePointInTimeFinderAsInternalUser({
saved_objects: [
{
...existingDecryptedRule,
attributes: {
...existingDecryptedRule.attributes,
actions: [
{
actionRef: 'action_0',
actionTypeId: 'test',
params: {},
uuid: '111',
},
],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
references: [
{
name: 'action_0',
type: 'action',
id: '1',
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
] as any,
},
],
});
const result = await rulesClient.bulkEdit({
operations: [
{
field: 'schedule',
operation: 'set',
value: { interval: '10m' },
},
],
});
expect(result.errors).toHaveLength(0);
expect(result.rules).toHaveLength(1);
});
});
describe('paramsModifier', () => {
@ -2579,7 +3314,13 @@ describe('bulkEdit()', () => {
const result = await rulesClient.bulkEdit({
filter: '',
operations: [],
operations: [
{
field: 'tags',
operation: 'add',
value: ['test-1'],
},
],
paramsModifier: async (params) => {
params.index = ['test-index-*'];

View file

@ -15,6 +15,8 @@ import {
SavedObjectsFindResult,
SavedObjectsUpdateResponse,
} from '@kbn/core/server';
import { validateSystemActions } from '../../../../lib/validate_system_actions';
import { RuleAction, RuleSystemAction } from '../../../../../common';
import { RULE_SAVED_OBJECT_TYPE } from '../../../../saved_objects';
import { BulkActionSkipResult } from '../../../../../common/bulk_edit';
import { RuleTypeRegistry } from '../../../../types';
@ -32,10 +34,10 @@ import {
retryIfBulkEditConflicts,
applyBulkEditOperation,
buildKueryNodeFilter,
injectReferencesIntoActions,
getBulkSnooze,
getBulkUnsnooze,
verifySnoozeScheduleLimit,
injectReferencesIntoActions,
} from '../../../../rules_client/common';
import {
alertingAuthorizationFilterOpts,
@ -56,6 +58,7 @@ import {
RuleBulkOperationAggregation,
RulesClientContext,
NormalizedAlertActionWithGeneratedValues,
NormalizedAlertAction,
} from '../../../../rules_client/types';
import { migrateLegacyActions } from '../../../../rules_client/lib';
import {
@ -78,6 +81,10 @@ import {
transformRuleDomainToRule,
} from '../../transforms';
import { validateScheduleLimit, ValidateScheduleLimitResult } from '../get_schedule_frequency';
import {
bulkEditDefaultActionsSchema,
bulkEditSystemActionsSchema,
} from './schemas/bulk_edit_rules_option_schemas';
const isValidInterval = (interval: string | undefined): interval is string => {
return interval !== undefined;
@ -117,6 +124,7 @@ export async function bulkEditRules<Params extends RuleParams>(
): Promise<BulkEditResult<Params>> {
const queryFilter = (options as BulkEditOptionsFilter<Params>).filter;
const ids = (options as BulkEditOptionsIds<Params>).ids;
const actionsClient = await context.getActionsClient();
if (ids && queryFilter) {
throw Boom.badRequest(
@ -231,13 +239,17 @@ export async function bulkEditRules<Params extends RuleParams>(
// fix the type cast from SavedObjectsBulkUpdateObject to SavedObjectsBulkUpdateObject
// when we are doing the bulk create and this should fix itself
const ruleType = context.ruleTypeRegistry.get(attributes.alertTypeId!);
const ruleDomain = transformRuleAttributesToRuleDomain<Params>(attributes as RuleAttributes, {
id,
logger: context.logger,
ruleType,
references,
omitGeneratedValues: false,
});
const ruleDomain = transformRuleAttributesToRuleDomain<Params>(
attributes as RuleAttributes,
{
id,
logger: context.logger,
ruleType,
references,
omitGeneratedValues: false,
},
(connectorId: string) => actionsClient.isSystemAction(connectorId)
);
try {
ruleDomainSchema.validate(ruleDomain);
} catch (e) {
@ -358,7 +370,6 @@ async function bulkEditRulesOcc<Params extends RuleParams>(
skipped: [],
};
}
const { result, apiKeysToInvalidate } =
rules.length > 0
? await saveBulkUpdatedRules({
@ -478,7 +489,8 @@ async function updateRuleAttributesAndParamsInMemory<Params extends RuleParams>(
logger: context.logger,
ruleType: context.ruleTypeRegistry.get(rule.attributes.alertTypeId),
references: rule.references,
}
},
context.isSystemAction
);
const {
@ -490,7 +502,7 @@ async function updateRuleAttributesAndParamsInMemory<Params extends RuleParams>(
context,
operations,
rule: ruleDomain,
ruleActions: ruleActions as RuleDomain['actions'], // TODO (http-versioning) Remove this cast once we fix injectReferencesIntoActions
ruleActions,
ruleType,
});
@ -532,9 +544,9 @@ async function updateRuleAttributesAndParamsInMemory<Params extends RuleParams>(
);
const {
actions: rawAlertActions,
references,
params: updatedParams,
actions: actionsWithRefs,
} = await extractReferences(
context,
ruleType,
@ -542,10 +554,13 @@ async function updateRuleAttributesAndParamsInMemory<Params extends RuleParams>(
validatedMutatedAlertTypeParams
);
const ruleAttributes = transformRuleDomainToRuleAttributes(updatedRule, {
legacyId: rule.attributes.legacyId,
actionsWithRefs: rawAlertActions,
paramsWithRefs: updatedParams as RuleAttributes['params'],
const ruleAttributes = transformRuleDomainToRuleAttributes({
actionsWithRefs,
rule: updatedRule,
params: {
legacyId: rule.attributes.legacyId,
paramsWithRefs: updatedParams as RuleAttributes['params'],
},
});
const { apiKeyAttributes } = await prepareApiKeys(
@ -563,7 +578,7 @@ async function updateRuleAttributesAndParamsInMemory<Params extends RuleParams>(
ruleAttributes,
apiKeyAttributes,
updatedParams,
rawAlertActions,
ruleAttributes.actions,
username
);
@ -621,9 +636,11 @@ async function getUpdatedAttributesFromOperations<Params extends RuleParams>({
context: RulesClientContext;
operations: BulkEditOperation[];
rule: RuleDomain<Params>;
ruleActions: RuleDomain['actions'];
ruleActions: RuleDomain['actions'] | RuleDomain['systemActions'];
ruleType: RuleType;
}) {
const actionsClient = await context.getActionsClient();
let updatedRule = cloneDeep(rule);
let updatedRuleActions = ruleActions;
let hasUpdateApiKeyOperation = false;
@ -636,21 +653,53 @@ async function getUpdatedAttributesFromOperations<Params extends RuleParams>({
// the `isAttributesUpdateSkipped` flag to false.
switch (operation.field) {
case 'actions': {
const systemActions = operation.value.filter((action): action is RuleSystemAction =>
actionsClient.isSystemAction(action.id)
);
if (systemActions.length > 0) {
try {
bulkEditSystemActionsSchema.validate(systemActions);
} catch (error) {
throw Boom.badRequest(`Error validating bulk edit rules operations - ${error.message}`);
}
}
const defaultActions = operation.value.filter(
(action): action is RuleAction => !actionsClient.isSystemAction(action.id)
);
if (defaultActions.length > 0) {
try {
bulkEditDefaultActionsSchema.validate(defaultActions);
} catch (error) {
throw Boom.badRequest(`Error validating bulk edit rules operations - ${error.message}`);
}
}
const { actions: genActions, systemActions: genSystemActions } =
await addGeneratedActionValues(defaultActions, systemActions, context);
const updatedOperation = {
...operation,
value: await addGeneratedActionValues(operation.value, context),
value: [...genActions, ...genSystemActions],
};
await validateSystemActions({
actionsClient,
connectorAdapterRegistry: context.connectorAdapterRegistry,
systemActions: genSystemActions,
});
try {
await validateActions(context, ruleType, {
...updatedRule,
actions: updatedOperation.value,
actions: genActions,
systemActions: genSystemActions,
});
} catch (e) {
// If validateActions fails on the first attempt, it may be because of legacy rule-level frequency params
updatedRule = await attemptToMigrateLegacyFrequency(
context,
updatedOperation,
operation.field,
genActions,
updatedRule,
ruleType
);
@ -669,23 +718,27 @@ async function getUpdatedAttributesFromOperations<Params extends RuleParams>({
break;
}
case 'snoozeSchedule': {
if (operation.operation === 'set') {
const snoozeAttributes = getBulkSnooze<Params>(
updatedRule,
operation.value as RuleSnoozeSchedule
);
try {
verifySnoozeScheduleLimit(snoozeAttributes.snoozeSchedule);
} catch (error) {
throw Error(`Error updating rule: could not add snooze - ${error.message}`);
}
updatedRule = {
...updatedRule,
muteAll: snoozeAttributes.muteAll,
snoozeSchedule: snoozeAttributes.snoozeSchedule as RuleDomain['snoozeSchedule'],
};
}
if (operation.operation === 'delete') {
const idsToDelete = operation.value && [...operation.value];
if (idsToDelete?.length === 0) {
@ -702,18 +755,25 @@ async function getUpdatedAttributesFromOperations<Params extends RuleParams>({
snoozeSchedule: snoozeAttributes.snoozeSchedule as RuleDomain['snoozeSchedule'],
};
}
isAttributesUpdateSkipped = false;
break;
}
case 'apiKey': {
hasUpdateApiKeyOperation = true;
isAttributesUpdateSkipped = false;
break;
}
default: {
if (operation.field === 'schedule') {
validateScheduleOperation(operation.value, updatedRule.actions, rule.id);
const defaultActions = updatedRule.actions.filter(
(action) => !actionsClient.isSystemAction(action.id)
);
validateScheduleOperation(operation.value, defaultActions, rule.id);
}
const { modifiedAttributes, isAttributeModified } = applyBulkEditOperation(
operation,
updatedRule
@ -923,18 +983,19 @@ async function saveBulkUpdatedRules({
async function attemptToMigrateLegacyFrequency<Params extends RuleParams>(
context: RulesClientContext,
operation: BulkEditOperation,
operationField: BulkEditOperation['field'],
actions: NormalizedAlertAction[],
rule: RuleDomain<Params>,
ruleType: RuleType
) {
if (operation.field !== 'actions')
if (operationField !== 'actions')
throw new Error('Can only perform frequency migration on an action operation');
// Try to remove the rule-level frequency params, and then validate actions
if (typeof rule.notifyWhen !== 'undefined') rule.notifyWhen = undefined;
if (rule.throttle) rule.throttle = undefined;
await validateActions(context, ruleType, {
...rule,
actions: operation.value,
actions,
});
return rule;
}

View file

@ -6,7 +6,7 @@
*/
import { schema } from '@kbn/config-schema';
import { rRuleRequestSchema } from '../../../../r_rule/schemas';
import { notifyWhenSchema } from '../../../schemas';
import { notifyWhenSchema, actionAlertsFilterSchema } from '../../../schemas';
import { validateDuration } from '../../../validation';
import { validateSnoozeSchedule } from '../validation';
@ -26,7 +26,7 @@ const bulkEditRuleSnoozeScheduleSchemaWithValidation = schema.object(
{ validate: validateSnoozeSchedule }
);
const bulkEditActionSchema = schema.object({
const bulkEditDefaultActionSchema = schema.object({
group: schema.string(),
id: schema.string(),
params: schema.recordOf(schema.string(), schema.any(), { defaultValue: {} }),
@ -38,8 +38,19 @@ const bulkEditActionSchema = schema.object({
notifyWhen: notifyWhenSchema,
})
),
alertsFilter: schema.maybe(actionAlertsFilterSchema),
});
export const bulkEditDefaultActionsSchema = schema.arrayOf(bulkEditDefaultActionSchema);
export const bulkEditSystemActionSchema = schema.object({
id: schema.string(),
params: schema.recordOf(schema.string(), schema.maybe(schema.any()), { defaultValue: {} }),
uuid: schema.maybe(schema.string()),
});
export const bulkEditSystemActionsSchema = schema.arrayOf(bulkEditSystemActionSchema);
const bulkEditTagSchema = schema.object({
operation: schema.oneOf([schema.literal('add'), schema.literal('delete'), schema.literal('set')]),
field: schema.literal('tags'),
@ -49,7 +60,7 @@ const bulkEditTagSchema = schema.object({
const bulkEditActionsSchema = schema.object({
operation: schema.oneOf([schema.literal('add'), schema.literal('set')]),
field: schema.literal('actions'),
value: schema.arrayOf(bulkEditActionSchema),
value: schema.arrayOf(schema.oneOf([bulkEditDefaultActionSchema, bulkEditSystemActionSchema])),
});
const bulkEditScheduleSchema = schema.object({

View file

@ -22,6 +22,7 @@ import { AlertingAuthorization } from '../../../../authorization/alerting_author
import { alertsServiceMock } from '../../../../alerts_service/alerts_service.mock';
import { ALERT_RULE_UUID, ALERT_UUID } from '@kbn/rule-data-utils';
import { ConcreteTaskInstance, TaskStatus } from '@kbn/task-manager-plugin/server';
import { ConnectorAdapterRegistry } from '../../../../connector_adapters/connector_adapter_registry';
const taskManager = taskManagerMock.createStart();
const ruleTypeRegistry = ruleTypeRegistryMock.create();
@ -61,6 +62,8 @@ const rulesClientParams: jest.Mocked<ConstructorOptions> = {
getAlertIndicesAlias: jest.fn(),
alertsService,
uiSettings: uiSettingsServiceMock.createStartContract(),
isSystemAction: jest.fn(),
connectorAdapterRegistry: new ConnectorAdapterRegistry(),
};
describe('bulkUntrackAlerts()', () => {

View file

@ -8,6 +8,7 @@ import Semver from 'semver';
import Boom from '@hapi/boom';
import { SavedObject, SavedObjectsUtils } from '@kbn/core/server';
import { withSpan } from '@kbn/apm-utils';
import { validateSystemActions } from '../../../../lib/validate_system_actions';
import { RULE_SAVED_OBJECT_TYPE } from '../../../../saved_objects';
import { parseDuration, getRuleCircuitBreakerErrorMessage } from '../../../../../common';
import { WriteOperations, AlertingAuthorizationEntity } from '../../../../authorization';
@ -24,7 +25,7 @@ import {
} from '../../../../rules_client/lib';
import { generateAPIKeyName, apiKeyAsRuleDomainProperties } from '../../../../rules_client/common';
import { ruleAuditEvent, RuleAuditAction } from '../../../../rules_client/common/audit_events';
import { RulesClientContext, NormalizedAlertAction } from '../../../../rules_client/types';
import { RulesClientContext } from '../../../../rules_client/types';
import { RuleDomain, RuleParams } from '../../types';
import { SanitizedRule } from '../../../../types';
import {
@ -55,14 +56,18 @@ export async function createRule<Params extends RuleParams = never>(
// TODO (http-versioning): This should be of type Rule, change this when all rule types are fixed
): Promise<SanitizedRule<Params>> {
const { data: initialData, options, allowMissingConnectorSecrets } = createParams;
const actionsClient = await context.getActionsClient();
const { actions: genAction, systemActions: genSystemActions } = await addGeneratedActionValues(
initialData.actions,
initialData.systemActions,
context
);
// TODO (http-versioning): Remove this cast when we fix addGeneratedActionValues
const data = {
...initialData,
actions: await addGeneratedActionValues(
initialData.actions as NormalizedAlertAction[],
context
),
actions: genAction,
systemActions: genSystemActions,
};
const id = options?.id || SavedObjectsUtils.generateId();
@ -144,6 +149,14 @@ export async function createRule<Params extends RuleParams = never>(
validateActions(context, ruleType, data, allowMissingConnectorSecrets)
);
await withSpan({ name: 'validateSystemActions', type: 'rules' }, () =>
validateSystemActions({
actionsClient,
connectorAdapterRegistry: context.connectorAdapterRegistry,
systemActions: data.systemActions,
})
);
// Throw error if schedule interval is less than the minimum and we are enforcing it
const intervalInMs = parseDuration(data.schedule.interval);
if (
@ -155,13 +168,14 @@ export async function createRule<Params extends RuleParams = never>(
);
}
const allActions = [...data.actions, ...(data.systemActions ?? [])];
// Extract saved object references for this rule
const {
references,
params: updatedParams,
actions,
actions: actionsWithRefs,
} = await withSpan({ name: 'extractReferences', type: 'rules' }, () =>
extractReferences(context, ruleType, data.actions, validatedRuleTypeParams)
extractReferences(context, ruleType, allActions, validatedRuleTypeParams)
);
const createTime = Date.now();
@ -170,10 +184,12 @@ export async function createRule<Params extends RuleParams = never>(
const notifyWhen = getRuleNotifyWhenType(data.notifyWhen ?? null, data.throttle ?? null);
const throttle = data.throttle ?? null;
const { systemActions, actions: actionToNotUse, ...restData } = data;
// Convert domain rule object to ES rule attributes
const ruleAttributes = transformRuleDomainToRuleAttributes(
{
...data,
const ruleAttributes = transformRuleDomainToRuleAttributes({
actionsWithRefs,
rule: {
...restData,
// TODO (http-versioning) create a rule domain version of this function
// Right now this works because the 2 types can interop but it's not ideal
...apiKeyAsRuleDomainProperties(createdAPIKey, username, isAuthTypeApiKey),
@ -192,13 +208,12 @@ export async function createRule<Params extends RuleParams = never>(
revision: 0,
running: false,
},
{
params: {
legacyId,
actionsWithRefs: actions,
// @ts-expect-error upgrade typescript v4.9.5
paramsWithRefs: updatedParams,
}
);
},
});
const createdRuleSavedObject: SavedObject<RuleAttributes> = await withSpan(
{ name: 'createRuleSavedObject', type: 'rules' },
@ -221,7 +236,8 @@ export async function createRule<Params extends RuleParams = never>(
logger: context.logger,
ruleType: context.ruleTypeRegistry.get(createdRuleSavedObject.attributes.alertTypeId),
references,
}
},
(connectorId: string) => actionsClient.isSystemAction(connectorId)
);
// Try to validate created rule, but don't throw.

View file

@ -9,6 +9,30 @@ import { schema } from '@kbn/config-schema';
import { validateDuration } from '../../../validation';
import { notifyWhenSchema, actionAlertsFilterSchema, alertDelaySchema } from '../../../schemas';
export const defaultActionSchema = schema.object({
group: schema.string(),
id: schema.string(),
actionTypeId: schema.maybe(schema.string()),
params: schema.recordOf(schema.string(), schema.maybe(schema.any()), { defaultValue: {} }),
frequency: schema.maybe(
schema.object({
summary: schema.boolean(),
notifyWhen: notifyWhenSchema,
throttle: schema.nullable(schema.string({ validate: validateDuration })),
})
),
uuid: schema.maybe(schema.string()),
alertsFilter: schema.maybe(actionAlertsFilterSchema),
useAlertDataForTemplate: schema.maybe(schema.boolean()),
});
export const systemActionSchema = schema.object({
id: schema.string(),
actionTypeId: schema.maybe(schema.string()),
params: schema.recordOf(schema.string(), schema.maybe(schema.any()), { defaultValue: {} }),
uuid: schema.maybe(schema.string()),
});
export const createRuleDataSchema = schema.object({
name: schema.string(),
alertTypeId: schema.string(),
@ -20,24 +44,13 @@ export const createRuleDataSchema = schema.object({
schedule: schema.object({
interval: schema.string({ validate: validateDuration }),
}),
actions: schema.arrayOf(
schema.object({
group: schema.string(),
id: schema.string(),
actionTypeId: schema.maybe(schema.string()),
params: schema.recordOf(schema.string(), schema.maybe(schema.any()), { defaultValue: {} }),
frequency: schema.maybe(
schema.object({
summary: schema.boolean(),
notifyWhen: notifyWhenSchema,
throttle: schema.nullable(schema.string({ validate: validateDuration })),
})
),
uuid: schema.maybe(schema.string()),
alertsFilter: schema.maybe(actionAlertsFilterSchema),
useAlertDataForTemplate: schema.maybe(schema.boolean()),
}),
{ defaultValue: [] }
actions: schema.arrayOf(defaultActionSchema, {
defaultValue: [],
}),
systemActions: schema.maybe(
schema.arrayOf(systemActionSchema, {
defaultValue: [],
})
),
notifyWhen: schema.maybe(schema.nullable(notifyWhenSchema)),
alertDelay: schema.maybe(alertDelaySchema),

View file

@ -5,4 +5,8 @@
* 2.0.
*/
export { createRuleDataSchema } from './create_rule_data_schema';
export {
createRuleDataSchema,
defaultActionSchema,
systemActionSchema,
} from './create_rule_data_schema';

View file

@ -6,10 +6,12 @@
*/
import { TypeOf } from '@kbn/config-schema';
import { createRuleDataSchema } from '../schemas';
import { createRuleDataSchema, defaultActionSchema, systemActionSchema } from '../schemas';
import { RuleParams } from '../../../types';
type CreateRuleDataType = TypeOf<typeof createRuleDataSchema>;
type CreateRuleActionDataType = TypeOf<typeof defaultActionSchema>;
type CreateRuleSystemActionDataType = TypeOf<typeof systemActionSchema>;
export interface CreateRuleData<Params extends RuleParams = never> {
name: CreateRuleDataType['name'];
@ -20,7 +22,8 @@ export interface CreateRuleData<Params extends RuleParams = never> {
throttle?: CreateRuleDataType['throttle'];
params: Params;
schedule: CreateRuleDataType['schedule'];
actions: CreateRuleDataType['actions'];
actions: CreateRuleActionDataType[];
systemActions?: CreateRuleSystemActionDataType[];
notifyWhen?: CreateRuleDataType['notifyWhen'];
alertDelay?: CreateRuleDataType['alertDelay'];
}

View file

@ -21,6 +21,7 @@ import { actionsAuthorizationMock } from '@kbn/actions-plugin/server/mocks';
import { AlertingAuthorization } from '../../../../authorization/alerting_authorization';
import { ActionsAuthorization } from '@kbn/actions-plugin/server';
import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks';
import { ConnectorAdapterRegistry } from '../../../../connector_adapters/connector_adapter_registry';
const taskManager = taskManagerMock.createStart();
const ruleTypeRegistry = ruleTypeRegistryMock.create();
@ -54,9 +55,11 @@ const rulesClientParams: jest.Mocked<ConstructorOptions> = {
minimumScheduleInterval: { value: '1m', enforce: false },
isAuthenticationTypeAPIKey: jest.fn(),
getAuthenticationAPIKey: jest.fn(),
connectorAdapterRegistry: new ConnectorAdapterRegistry(),
getAlertIndicesAlias: jest.fn(),
alertsService: null,
uiSettings: uiSettingsServiceMock.createStartContract(),
isSystemAction: jest.fn(),
};
const getMockAggregationResult = (

View file

@ -0,0 +1,144 @@
/*
* 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 { ConstructorOptions, RulesClient } from '../../../../rules_client/rules_client';
import {
savedObjectsClientMock,
loggingSystemMock,
savedObjectsRepositoryMock,
uiSettingsServiceMock,
} from '@kbn/core/server/mocks';
import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks';
import { ruleTypeRegistryMock } from '../../../../rule_type_registry.mock';
import { alertingAuthorizationMock } from '../../../../authorization/alerting_authorization.mock';
import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks';
import { actionsAuthorizationMock } from '@kbn/actions-plugin/server/mocks';
import { AlertingAuthorization } from '../../../../authorization/alerting_authorization';
import { ActionsAuthorization } from '@kbn/actions-plugin/server';
import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks';
import { ConnectorAdapterRegistry } from '../../../../connector_adapters/connector_adapter_registry';
import { RULE_SAVED_OBJECT_TYPE } from '../../../../saved_objects';
import { getBeforeSetup } from '../../../../rules_client/tests/lib';
describe('resolve', () => {
const taskManager = taskManagerMock.createStart();
const ruleTypeRegistry = ruleTypeRegistryMock.create();
const unsecuredSavedObjectsClient = savedObjectsClientMock.create();
const encryptedSavedObjects = encryptedSavedObjectsMock.createClient();
const authorization = alertingAuthorizationMock.create();
const actionsAuthorization = actionsAuthorizationMock.create();
const auditLogger = auditLoggerMock.create();
const internalSavedObjectsRepository = savedObjectsRepositoryMock.create();
const kibanaVersion = 'v8.2.0';
const createAPIKeyMock = jest.fn();
const isAuthenticationTypeApiKeyMock = jest.fn();
const getAuthenticationApiKeyMock = jest.fn();
const rulesClientParams: jest.Mocked<ConstructorOptions> = {
taskManager,
ruleTypeRegistry,
unsecuredSavedObjectsClient,
authorization: authorization as unknown as AlertingAuthorization,
actionsAuthorization: actionsAuthorization as unknown as ActionsAuthorization,
spaceId: 'default',
namespace: 'default',
getUserName: jest.fn(),
createAPIKey: createAPIKeyMock,
logger: loggingSystemMock.create().get(),
internalSavedObjectsRepository,
encryptedSavedObjectsClient: encryptedSavedObjects,
getActionsClient: jest.fn(),
getEventLogClient: jest.fn(),
kibanaVersion,
auditLogger,
maxScheduledPerMinute: 10000,
minimumScheduleInterval: { value: '1m', enforce: false },
isAuthenticationTypeAPIKey: isAuthenticationTypeApiKeyMock,
getAuthenticationAPIKey: getAuthenticationApiKeyMock,
connectorAdapterRegistry: new ConnectorAdapterRegistry(),
isSystemAction: jest.fn(),
getAlertIndicesAlias: jest.fn(),
alertsService: null,
uiSettings: uiSettingsServiceMock.createStartContract(),
};
let rulesClient: RulesClient;
beforeEach(() => {
jest.clearAllMocks();
getBeforeSetup(rulesClientParams, taskManager, ruleTypeRegistry);
rulesClient = new RulesClient(rulesClientParams);
});
describe('actions', () => {
it('transform actions correctly', async () => {
unsecuredSavedObjectsClient.resolve.mockResolvedValue({
outcome: 'exactMatch',
saved_object: {
id: 'test-rule',
type: RULE_SAVED_OBJECT_TYPE,
attributes: {
alertTypeId: '123',
schedule: { interval: '10s' },
params: {
bar: true,
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
actions: [
{
frequency: {
notifyWhen: 'onActiveAlert' as const,
summary: false,
throttle: null,
},
group: 'default',
params: {},
actionRef: 'action_0',
actionTypeId: 'test-1',
uuid: '222',
},
{
params: {},
actionRef: 'system_action:system_action-id',
actionTypeId: 'test-2',
uuid: '222',
},
],
notifyWhen: 'onActiveAlert',
executionStatus: {},
},
references: [
{
name: 'action_0',
type: 'action',
id: '1',
},
],
},
});
const res = await rulesClient.resolve({ id: 'test-rule' });
expect(res.actions).toEqual([
{
actionTypeId: 'test-1',
frequency: { notifyWhen: 'onActiveAlert', summary: false, throttle: null },
group: 'default',
id: '1',
params: {},
uuid: '222',
},
]);
expect(res.systemActions).toEqual([
{ actionTypeId: 'test-2', id: 'system_action-id', params: {}, uuid: '222' },
]);
});
});
});

View file

@ -66,13 +66,17 @@ Promise<ResolvedSanitizedRule<Params>> {
})
);
const ruleDomain = transformRuleAttributesToRuleDomain(result.attributes, {
id: result.id,
logger: context.logger,
ruleType: context.ruleTypeRegistry.get(result.attributes.alertTypeId),
references: result.references,
includeSnoozeData,
});
const ruleDomain = transformRuleAttributesToRuleDomain(
result.attributes,
{
id: result.id,
logger: context.logger,
ruleType: context.ruleTypeRegistry.get(result.attributes.alertTypeId),
references: result.references,
includeSnoozeData,
},
context.isSystemAction
);
const rule = transformRuleDomainToRule(ruleDomain);

View file

@ -23,6 +23,7 @@ import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks';
import { getBeforeSetup } from '../../../../rules_client/tests/lib';
import { RecoveredActionGroup } from '../../../../../common';
import { RegistryRuleType } from '../../../../rule_type_registry';
import { ConnectorAdapterRegistry } from '../../../../connector_adapters/connector_adapter_registry';
import { RULE_SAVED_OBJECT_TYPE } from '../../../../saved_objects';
const taskManager = taskManagerMock.createStart();
@ -56,9 +57,11 @@ const rulesClientParams: jest.Mocked<ConstructorOptions> = {
kibanaVersion,
isAuthenticationTypeAPIKey: jest.fn(),
getAuthenticationAPIKey: jest.fn(),
connectorAdapterRegistry: new ConnectorAdapterRegistry(),
getAlertIndicesAlias: jest.fn(),
alertsService: null,
uiSettings: uiSettingsServiceMock.createStartContract(),
isSystemAction: jest.fn(),
};
const listedTypes = new Set<RegistryRuleType>([

View file

@ -44,7 +44,7 @@ const actionFrequencySchema = schema.object({
/**
* Unsanitized (domain) action schema, used by internal rules clients
*/
export const actionDomainSchema = schema.object({
export const defaultActionDomainSchema = schema.object({
uuid: schema.maybe(schema.string()),
group: schema.string(),
id: schema.string(),
@ -52,7 +52,14 @@ export const actionDomainSchema = schema.object({
params: actionParamsSchema,
frequency: schema.maybe(actionFrequencySchema),
alertsFilter: schema.maybe(actionDomainAlertsFilterSchema),
useAlertDataAsTemplate: schema.maybe(schema.boolean()),
useAlertDataForTemplate: schema.maybe(schema.boolean()),
});
export const systemActionDomainSchema = schema.object({
id: schema.string(),
actionTypeId: schema.string(),
params: actionParamsSchema,
uuid: schema.maybe(schema.string()),
});
export const actionAlertsFilterSchema = schema.object({
@ -60,7 +67,7 @@ export const actionAlertsFilterSchema = schema.object({
timeframe: schema.maybe(actionAlertsFilterTimeFrameSchema),
});
export const actionSchema = schema.object({
export const defaultActionSchema = schema.object({
uuid: schema.maybe(schema.string()),
group: schema.string(),
id: schema.string(),
@ -70,3 +77,11 @@ export const actionSchema = schema.object({
alertsFilter: schema.maybe(actionAlertsFilterSchema),
useAlertDataForTemplate: schema.maybe(schema.boolean()),
});
export const systemActionSchema = schema.object({
id: schema.string(),
actionTypeId: schema.string(),
params: actionParamsSchema,
uuid: schema.maybe(schema.string()),
useAlertDataAsTemplate: schema.maybe(schema.boolean()),
});

View file

@ -18,8 +18,8 @@ export {
export {
actionParamsSchema,
actionDomainSchema,
actionSchema,
defaultActionDomainSchema,
systemActionDomainSchema,
actionAlertsFilterSchema,
} from './action_schemas';

View file

@ -15,7 +15,12 @@ import {
import { rRuleSchema } from '../../r_rule/schemas';
import { dateSchema } from './date_schema';
import { notifyWhenSchema } from './notify_when_schema';
import { actionDomainSchema, actionSchema } from './action_schemas';
import {
defaultActionDomainSchema,
defaultActionSchema,
systemActionDomainSchema,
systemActionSchema,
} from './action_schemas';
export const ruleParamsSchema = schema.recordOf(schema.string(), schema.maybe(schema.any()));
export const mappedParamsSchema = schema.recordOf(schema.string(), schema.maybe(schema.any()));
@ -147,7 +152,8 @@ export const ruleDomainSchema = schema.object({
alertTypeId: schema.string(),
consumer: schema.string(),
schedule: intervalScheduleSchema,
actions: schema.arrayOf(actionDomainSchema),
actions: schema.arrayOf(defaultActionDomainSchema),
systemActions: schema.maybe(schema.arrayOf(systemActionDomainSchema)),
params: ruleParamsSchema,
mapped_params: schema.maybe(mappedParamsSchema),
scheduledTaskId: schema.maybe(schema.string()),
@ -186,7 +192,8 @@ export const ruleSchema = schema.object({
alertTypeId: schema.string(),
consumer: schema.string(),
schedule: intervalScheduleSchema,
actions: schema.arrayOf(actionSchema),
actions: schema.arrayOf(defaultActionSchema),
systemActions: schema.maybe(schema.arrayOf(systemActionSchema)),
params: ruleParamsSchema,
mapped_params: schema.maybe(mappedParamsSchema),
scheduledTaskId: schema.maybe(schema.string()),

View file

@ -8,3 +8,4 @@
export { transformRuleAttributesToRuleDomain } from './transform_rule_attributes_to_rule_domain';
export { transformRuleDomainToRuleAttributes } from './transform_rule_domain_to_rule_attributes';
export { transformRuleDomainToRule } from './transform_rule_domain_to_rule';
export { transformRawActionsToDomainActions } from './transform_raw_actions_to_domain_actions';

View file

@ -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 { RuleActionAttributes } from '../../../data/rule/types';
import {
transformRawActionsToDomainActions,
transformRawActionsToDomainSystemActions,
} from './transform_raw_actions_to_domain_actions';
const defaultAction: RuleActionAttributes = {
group: 'default',
uuid: '1',
actionRef: 'default-action-ref',
actionTypeId: '.test',
params: {},
frequency: {
summary: false,
notifyWhen: 'onThrottleInterval',
throttle: '1m',
},
alertsFilter: { query: { kql: 'test:1', dsl: '{}', filters: [] } },
};
const systemAction: RuleActionAttributes = {
actionRef: 'system_action:my-system-action-id',
uuid: '123',
actionTypeId: '.test-system-action',
params: {},
};
const isSystemAction = (id: string) => id === 'my-system-action-id';
describe('transformRawActionsToDomainActions', () => {
it('transforms the actions correctly', () => {
const res = transformRawActionsToDomainActions({
actions: [defaultAction, systemAction],
ruleId: 'test-rule',
references: [{ name: 'default-action-ref', id: 'default-action-id', type: 'action' }],
isSystemAction,
});
expect(res).toMatchInlineSnapshot(`
Array [
Object {
"actionTypeId": ".test",
"alertsFilter": Object {
"query": Object {
"filters": Array [],
"kql": "test:1",
},
},
"frequency": Object {
"notifyWhen": "onThrottleInterval",
"summary": false,
"throttle": "1m",
},
"group": "default",
"id": "default-action-id",
"params": Object {},
"uuid": "1",
},
]
`);
});
});
describe('transformRawActionsToDomainSystemActions', () => {
it('transforms the system actions correctly', () => {
const res = transformRawActionsToDomainSystemActions({
actions: [defaultAction, systemAction],
ruleId: 'test-rule',
references: [{ name: 'default-action-ref', id: 'default-action-id', type: 'action' }],
isSystemAction,
});
expect(res).toMatchInlineSnapshot(`
Array [
Object {
"actionTypeId": ".test-system-action",
"id": "my-system-action-id",
"params": Object {},
"uuid": "123",
},
]
`);
});
});

View file

@ -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 { omit } from 'lodash';
import { SavedObjectReference } from '@kbn/core/server';
import { injectReferencesIntoActions } from '../../../rules_client/common';
import { RuleAttributes } from '../../../data/rule/types';
import { RawRule } from '../../../types';
import { RuleDomain } from '../types';
interface Args {
ruleId: string;
actions: RuleAttributes['actions'] | RawRule['actions'];
isSystemAction: (connectorId: string) => boolean;
omitGeneratedValues?: boolean;
references?: SavedObjectReference[];
}
export const transformRawActionsToDomainActions = ({
actions,
ruleId,
references,
omitGeneratedValues = true,
isSystemAction,
}: Args): RuleDomain['actions'] => {
const actionsWithInjectedRefs = actions
? injectReferencesIntoActions(ruleId, actions, references || [])
: [];
const ruleDomainActions = actionsWithInjectedRefs
.filter((action) => !isSystemAction(action.id))
.map((action) => {
const defaultAction = {
group: action.group ?? 'default',
id: action.id,
params: action.params,
actionTypeId: action.actionTypeId,
uuid: action.uuid,
...(action.frequency ? { frequency: action.frequency } : {}),
...(action.alertsFilter ? { alertsFilter: action.alertsFilter } : {}),
...(action.useAlertDataAsTemplate
? { useAlertDataAsTemplate: action.useAlertDataAsTemplate }
: {}),
};
if (omitGeneratedValues) {
return omit(defaultAction, 'alertsFilter.query.dsl');
}
return defaultAction;
});
return ruleDomainActions;
};
export const transformRawActionsToDomainSystemActions = ({
actions,
ruleId,
references,
omitGeneratedValues = true,
isSystemAction,
}: Args): RuleDomain['systemActions'] => {
const actionsWithInjectedRefs = actions
? injectReferencesIntoActions(ruleId, actions, references || [])
: [];
const ruleDomainSystemActions = actionsWithInjectedRefs
.filter((action) => isSystemAction(action.id))
.map((action) => {
return {
id: action.id,
params: action.params,
actionTypeId: action.actionTypeId,
uuid: action.uuid,
...(action.useAlertDataAsTemplate
? { useAlertDataAsTemplate: action.useAlertDataAsTemplate }
: {}),
};
});
return ruleDomainSystemActions;
};

View file

@ -0,0 +1,142 @@
/*
* 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 { RecoveredActionGroup } from '../../../../common';
import { loggingSystemMock } from '@kbn/core-logging-server-mocks';
import { transformRuleAttributesToRuleDomain } from './transform_rule_attributes_to_rule_domain';
import { UntypedNormalizedRuleType } from '../../../rule_type_registry';
import { RuleActionAttributes } from '../../../data/rule/types';
const ruleType: jest.Mocked<UntypedNormalizedRuleType> = {
id: 'test.rule-type',
name: 'My test rule',
actionGroups: [{ id: 'default', name: 'Default' }, RecoveredActionGroup],
defaultActionGroupId: 'default',
minimumLicenseRequired: 'basic',
isExportable: true,
recoveryActionGroup: RecoveredActionGroup,
executor: jest.fn(),
producer: 'alerts',
cancelAlertsOnRuleTimeout: true,
ruleTaskTimeout: '5m',
autoRecoverAlerts: true,
doesSetRecoveryContext: true,
validate: {
params: { validate: (params) => params },
},
alerts: {
context: 'test',
mappings: { fieldMap: { field: { type: 'keyword', required: false } } },
shouldWrite: true,
},
category: 'test',
validLegacyConsumers: [],
};
const defaultAction: RuleActionAttributes = {
group: 'default',
uuid: '1',
actionRef: 'default-action-ref',
actionTypeId: '.test',
params: {},
frequency: {
summary: false,
notifyWhen: 'onThrottleInterval',
throttle: '1m',
},
alertsFilter: { query: { kql: 'test:1', dsl: '{}', filters: [] } },
};
const systemAction: RuleActionAttributes = {
actionRef: 'system_action:my-system-action-id',
uuid: '123',
actionTypeId: '.test-system-action',
params: {},
};
const isSystemAction = (id: string) => id === 'my-system-action-id';
describe('transformRuleAttributesToRuleDomain', () => {
const MOCK_API_KEY = Buffer.from('123:abc').toString('base64');
const logger = loggingSystemMock.create().get();
const references = [{ name: 'default-action-ref', type: 'action', id: 'default-action-id' }];
const rule = {
enabled: false,
tags: ['foo'],
createdBy: 'user',
createdAt: '2019-02-12T21:01:22.479Z',
updatedAt: '2019-02-12T21:01:22.479Z',
legacyId: null,
muteAll: false,
mutedInstanceIds: [],
snoozeSchedule: [],
alertTypeId: 'myType',
schedule: { interval: '1m' },
consumer: 'myApp',
scheduledTaskId: 'task-123',
executionStatus: {
lastExecutionDate: '2019-02-12T21:01:22.479Z',
status: 'pending' as const,
},
params: {},
throttle: null,
notifyWhen: null,
actions: [defaultAction, systemAction],
name: 'my rule name',
revision: 0,
updatedBy: 'user',
apiKey: MOCK_API_KEY,
apiKeyOwner: 'user',
};
it('transforms the actions correctly', () => {
const res = transformRuleAttributesToRuleDomain(
rule,
{
id: '1',
logger,
ruleType,
references,
},
isSystemAction
);
expect(res.actions).toMatchInlineSnapshot(`
Array [
Object {
"actionTypeId": ".test",
"alertsFilter": Object {
"query": Object {
"filters": Array [],
"kql": "test:1",
},
},
"frequency": Object {
"notifyWhen": "onThrottleInterval",
"summary": false,
"throttle": "1m",
},
"group": "default",
"id": "default-action-id",
"params": Object {},
"uuid": "1",
},
]
`);
expect(res.systemActions).toMatchInlineSnapshot(`
Array [
Object {
"actionTypeId": ".test-system-action",
"id": "my-system-action-id",
"params": Object {},
"uuid": "123",
},
]
`);
});
});

View file

@ -4,20 +4,21 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { omit, isEmpty } from 'lodash';
import { isEmpty } from 'lodash';
import { Logger } from '@kbn/core/server';
import { SavedObjectReference } from '@kbn/core/server';
import { ruleExecutionStatusValues } from '../constants';
import { getRuleSnoozeEndTime } from '../../../lib';
import { RuleDomain, Monitoring, RuleParams } from '../types';
import { RuleAttributes } from '../../../data/rule/types';
import { RawRule, PartialRule } from '../../../types';
import { PartialRule } from '../../../types';
import { UntypedNormalizedRuleType } from '../../../rule_type_registry';
import {
injectReferencesIntoActions,
injectReferencesIntoParams,
} from '../../../rules_client/common';
import { injectReferencesIntoParams } from '../../../rules_client/common';
import { getActiveScheduledSnoozes } from '../../../lib/is_rule_snoozed';
import {
transformRawActionsToDomainActions,
transformRawActionsToDomainSystemActions,
} from './transform_raw_actions_to_domain_actions';
const INITIAL_LAST_RUN_METRICS = {
duration: 0,
@ -120,7 +121,8 @@ interface TransformEsToRuleParams {
export const transformRuleAttributesToRuleDomain = <Params extends RuleParams = never>(
esRule: RuleAttributes,
transformParams: TransformEsToRuleParams
transformParams: TransformEsToRuleParams,
isSystemAction: (connectorId: string) => boolean
): RuleDomain<Params> => {
const { scheduledTaskId, executionStatus, monitoring, snoozeSchedule, lastRun } = esRule;
@ -141,6 +143,7 @@ export const transformRuleAttributesToRuleDomain = <Params extends RuleParams =
...(s.rRule.until ? { until: new Date(s.rRule.until).toISOString() } : {}),
},
}));
const includeSnoozeSchedule = snoozeSchedule !== undefined && !isEmpty(snoozeSchedule);
const isSnoozedUntil = includeSnoozeSchedule
? getRuleSnoozeEndTime({
@ -149,13 +152,21 @@ export const transformRuleAttributesToRuleDomain = <Params extends RuleParams =
})?.toISOString()
: null;
let actions = esRule.actions
? injectReferencesIntoActions(id, esRule.actions as RawRule['actions'], references || [])
: [];
if (omitGeneratedValues) {
actions = actions.map((ruleAction) => omit(ruleAction, 'alertsFilter.query.dsl'));
}
const ruleDomainActions: RuleDomain['actions'] = transformRawActionsToDomainActions({
ruleId: id,
actions: esRule.actions,
references,
isSystemAction,
omitGeneratedValues,
});
const ruleDomainSystemActions: RuleDomain['systemActions'] =
transformRawActionsToDomainSystemActions({
ruleId: id,
actions: esRule.actions,
references,
isSystemAction,
omitGeneratedValues,
});
const params = injectReferencesIntoParams<Params, RuleParams>(
id,
@ -177,7 +188,8 @@ export const transformRuleAttributesToRuleDomain = <Params extends RuleParams =
alertTypeId: esRule.alertTypeId,
consumer: esRule.consumer,
schedule: esRule.schedule,
actions: actions as RuleDomain['actions'],
actions: ruleDomainActions,
systemActions: ruleDomainSystemActions,
params,
mapped_params: esRule.mapped_params,
...(scheduledTaskId ? { scheduledTaskId } : {}),

View file

@ -26,6 +26,7 @@ export const transformRuleDomainToRule = <Params extends RuleParams = never>(
consumer: ruleDomain.consumer,
schedule: ruleDomain.schedule,
actions: ruleDomain.actions,
systemActions: ruleDomain.systemActions,
params: ruleDomain.params,
mapped_params: ruleDomain.mapped_params,
scheduledTaskId: ruleDomain.scheduledTaskId,

View file

@ -7,19 +7,24 @@
import { RuleDomain } from '../types';
import { RuleAttributes } from '../../../data/rule/types';
import { getMappedParams } from '../../../rules_client/common';
import { DenormalizedAction } from '../../../rules_client';
interface TransformRuleToEsParams {
legacyId: RuleAttributes['legacyId'];
actionsWithRefs: RuleAttributes['actions'];
paramsWithRefs: RuleAttributes['params'];
meta?: RuleAttributes['meta'];
}
export const transformRuleDomainToRuleAttributes = (
rule: Omit<RuleDomain, 'actions' | 'params'>,
params: TransformRuleToEsParams
): RuleAttributes => {
const { legacyId, actionsWithRefs, paramsWithRefs, meta } = params;
export const transformRuleDomainToRuleAttributes = ({
actionsWithRefs,
rule,
params,
}: {
actionsWithRefs: DenormalizedAction[];
rule: Omit<RuleDomain, 'actions' | 'params' | 'systemActions'>;
params: TransformRuleToEsParams;
}): RuleAttributes => {
const { legacyId, paramsWithRefs, meta } = params;
const mappedParams = getMappedParams(paramsWithRefs);
return {

View file

@ -12,7 +12,6 @@ import {
ruleExecutionStatusValues,
ruleExecutionStatusErrorReason,
ruleExecutionStatusWarningReason,
filterStateStore,
} from '../constants';
import {
ruleParamsSchema,
@ -20,7 +19,6 @@ import {
ruleExecutionStatusSchema,
ruleLastRunSchema,
monitoringSchema,
actionSchema,
ruleSchema,
ruleDomainSchema,
} from '../schemas';
@ -34,13 +32,11 @@ export type RuleExecutionStatusErrorReason =
typeof ruleExecutionStatusErrorReason[keyof typeof ruleExecutionStatusErrorReason];
export type RuleExecutionStatusWarningReason =
typeof ruleExecutionStatusWarningReason[keyof typeof ruleExecutionStatusWarningReason];
export type FilterStateStore = typeof filterStateStore[keyof typeof filterStateStore];
export type RuleParams = TypeOf<typeof ruleParamsSchema>;
export type RuleSnoozeSchedule = TypeOf<typeof snoozeScheduleSchema>;
export type RuleLastRun = TypeOf<typeof ruleLastRunSchema>;
export type Monitoring = TypeOf<typeof monitoringSchema>;
export type Action = TypeOf<typeof actionSchema>;
type RuleSchemaType = TypeOf<typeof ruleSchema>;
type RuleDomainSchemaType = TypeOf<typeof ruleDomainSchema>;
@ -62,6 +58,7 @@ export interface Rule<Params extends RuleParams = never> {
consumer: RuleSchemaType['consumer'];
schedule: RuleSchemaType['schedule'];
actions: RuleSchemaType['actions'];
systemActions?: RuleSchemaType['systemActions'];
params: Params;
mapped_params?: RuleSchemaType['mapped_params'];
scheduledTaskId?: RuleSchemaType['scheduledTaskId'];
@ -97,6 +94,7 @@ export interface RuleDomain<Params extends RuleParams = never> {
consumer: RuleDomainSchemaType['consumer'];
schedule: RuleDomainSchemaType['schedule'];
actions: RuleDomainSchemaType['actions'];
systemActions: RuleDomainSchemaType['systemActions'];
params: Params;
mapped_params?: RuleDomainSchemaType['mapped_params'];
scheduledTaskId?: RuleDomainSchemaType['scheduledTaskId'];

View file

@ -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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { schema } from '@kbn/config-schema';
import { ConnectorAdapterRegistry } from './connector_adapter_registry';
import type { ConnectorAdapter } from './types';
describe('ConnectorAdapterRegistry', () => {
const connectorAdapter: ConnectorAdapter = {
connectorTypeId: '.test',
ruleActionParamsSchema: schema.object({}),
buildActionParams: jest.fn(),
};
let registry: ConnectorAdapterRegistry;
beforeEach(() => {
registry = new ConnectorAdapterRegistry();
});
describe('has', () => {
it('returns true if the connector adapter is registered', () => {
registry.register(connectorAdapter);
expect(registry.has('.test')).toBe(true);
});
it('returns false if the connector adapter is not registered', () => {
expect(registry.has('.not-exist')).toBe(false);
});
});
describe('register', () => {
it('registers a connector adapter correctly', () => {
registry.register(connectorAdapter);
expect(registry.get('.test')).toEqual(connectorAdapter);
});
it('throws an error if the connector adapter exists', () => {
registry.register(connectorAdapter);
expect(() => registry.register(connectorAdapter)).toThrowErrorMatchingInlineSnapshot(
`".test is already registered to the ConnectorAdapterRegistry"`
);
});
});
describe('get', () => {
it('gets a connector adapter correctly', () => {
registry.register(connectorAdapter);
expect(registry.get('.test')).toEqual(connectorAdapter);
});
it('throws an error if the connector adapter does not exists', () => {
expect(() => registry.get('.not-exists')).toThrowErrorMatchingInlineSnapshot(
`"Connector adapter \\".not-exists\\" is not registered."`
);
});
});
});

View file

@ -0,0 +1,53 @@
/*
* 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 Boom from '@hapi/boom';
import { i18n } from '@kbn/i18n';
import { ConnectorAdapter, ConnectorAdapterParams } from './types';
export class ConnectorAdapterRegistry {
private readonly connectorAdapters: Map<string, ConnectorAdapter> = new Map();
public has(connectorTypeId: string): boolean {
return this.connectorAdapters.has(connectorTypeId);
}
public register<
RuleActionParams extends ConnectorAdapterParams = ConnectorAdapterParams,
ConnectorParams extends ConnectorAdapterParams = ConnectorAdapterParams
>(connectorAdapter: ConnectorAdapter<RuleActionParams, ConnectorParams>) {
if (this.has(connectorAdapter.connectorTypeId)) {
throw new Error(
`${connectorAdapter.connectorTypeId} is already registered to the ConnectorAdapterRegistry`
);
}
this.connectorAdapters.set(
connectorAdapter.connectorTypeId,
connectorAdapter as unknown as ConnectorAdapter
);
}
public get(connectorTypeId: string): ConnectorAdapter {
if (!this.connectorAdapters.has(connectorTypeId)) {
throw Boom.badRequest(
i18n.translate(
'xpack.alerting.connectorAdapterRegistry.get.missingConnectorAdapterErrorMessage',
{
defaultMessage: 'Connector adapter "{connectorTypeId}" is not registered.',
values: {
connectorTypeId,
},
}
)
);
}
return this.connectorAdapters.get(connectorTypeId)!;
}
}

View file

@ -0,0 +1,41 @@
/*
* 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 { ObjectType } from '@kbn/config-schema';
import type { RuleTypeParams, SanitizedRule } from '../../common';
import { CombinedSummarizedAlerts } from '../types';
type Rule = Pick<SanitizedRule<RuleTypeParams>, 'id' | 'name' | 'tags'>;
export interface ConnectorAdapterParams {
[x: string]: unknown;
}
interface BuildActionParamsArgs<RuleActionParams> {
alerts: CombinedSummarizedAlerts;
rule: Rule;
params: RuleActionParams;
spaceId: string;
ruleUrl?: string;
}
export interface ConnectorAdapter<
RuleActionParams extends ConnectorAdapterParams = ConnectorAdapterParams,
ConnectorParams extends ConnectorAdapterParams = ConnectorAdapterParams
> {
connectorTypeId: string;
/**
* The schema of the action persisted
* in the rule. The schema will be validated
* when a rule is created or updated.
* The schema should be backwards compatible
* and should never introduce any breaking
* changes.
*/
ruleActionParamsSchema: ObjectType;
buildActionParams: (args: BuildActionParamsArgs<RuleActionParams>) => ConnectorParams;
}

View file

@ -0,0 +1,92 @@
/*
* 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 { schema } from '@kbn/config-schema';
import { ConnectorAdapterRegistry } from './connector_adapter_registry';
import type { ConnectorAdapter } from './types';
import {
bulkValidateConnectorAdapterActionParams,
validateConnectorAdapterActionParams,
} from './validate_rule_action_params';
describe('validateRuleActionParams', () => {
const firstConnectorAdapter: ConnectorAdapter = {
connectorTypeId: '.test',
ruleActionParamsSchema: schema.object({ foo: schema.string() }),
buildActionParams: jest.fn(),
};
const secondConnectorAdapter: ConnectorAdapter = {
connectorTypeId: '.test-2',
ruleActionParamsSchema: schema.object({ bar: schema.string() }),
buildActionParams: jest.fn(),
};
let registry: ConnectorAdapterRegistry;
beforeEach(() => {
registry = new ConnectorAdapterRegistry();
});
describe('validateConnectorAdapterActionParams', () => {
it('should validate correctly invalid params', () => {
registry.register(firstConnectorAdapter);
expect(() =>
validateConnectorAdapterActionParams({
connectorAdapterRegistry: registry,
connectorTypeId: firstConnectorAdapter.connectorTypeId,
params: { foo: 5 },
})
).toThrowErrorMatchingInlineSnapshot(
`"Invalid system action params. System action type: .test - [foo]: expected value of type [string] but got [number]"`
);
});
it('should not throw if the connectorTypeId is not defined', () => {
registry.register(firstConnectorAdapter);
expect(() =>
validateConnectorAdapterActionParams({
connectorAdapterRegistry: registry,
params: {},
})
).not.toThrow();
});
it('should not throw if the connector adapter is not registered', () => {
expect(() =>
validateConnectorAdapterActionParams({
connectorAdapterRegistry: registry,
connectorTypeId: firstConnectorAdapter.connectorTypeId,
params: {},
})
).not.toThrow();
});
});
describe('bulkValidateConnectorAdapterActionParams', () => {
it('should validate correctly invalid params with multiple actions', () => {
const actions = [
{ actionTypeId: firstConnectorAdapter.connectorTypeId, params: { foo: 5 } },
{ actionTypeId: secondConnectorAdapter.connectorTypeId, params: { bar: 'test' } },
];
registry.register(firstConnectorAdapter);
registry.register(secondConnectorAdapter);
expect(() =>
bulkValidateConnectorAdapterActionParams({
connectorAdapterRegistry: registry,
actions,
})
).toThrowErrorMatchingInlineSnapshot(
`"Invalid system action params. System action type: .test - [foo]: expected value of type [string] but got [number]"`
);
});
});
});

View file

@ -0,0 +1,58 @@
/*
* 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 Boom from '@hapi/boom';
import { ConnectorAdapterRegistry } from './connector_adapter_registry';
interface ValidateSchemaArgs {
connectorAdapterRegistry: ConnectorAdapterRegistry;
connectorTypeId?: string;
params: Record<string, unknown>;
}
interface BulkValidateSchemaArgs {
connectorAdapterRegistry: ConnectorAdapterRegistry;
actions: Array<{ actionTypeId: string; params: Record<string, unknown> }>;
}
export const validateConnectorAdapterActionParams = ({
connectorAdapterRegistry,
connectorTypeId,
params,
}: ValidateSchemaArgs) => {
if (!connectorTypeId) {
return;
}
if (!connectorAdapterRegistry.has(connectorTypeId)) {
return;
}
const connectorAdapter = connectorAdapterRegistry.get(connectorTypeId);
const schema = connectorAdapter.ruleActionParamsSchema;
try {
schema.validate(params);
} catch (error) {
throw Boom.badRequest(
`Invalid system action params. System action type: ${connectorAdapter.connectorTypeId} - ${error.message}`
);
}
};
export const bulkValidateConnectorAdapterActionParams = ({
connectorAdapterRegistry,
actions,
}: BulkValidateSchemaArgs) => {
for (const action of actions) {
validateConnectorAdapterActionParams({
connectorAdapterRegistry,
connectorTypeId: action.actionTypeId,
params: action.params,
});
}
};

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import type { FilterStateStore } from '../constants';
import { FilterStateStore } from '@kbn/es-query';
export interface AlertsFilterAttributes {
query?: Record<string, unknown>;
@ -18,5 +18,5 @@ export interface AlertsFilterAttributes {
export interface AlertsFilterQueryAttributes {
kql: string;
filters: AlertsFilterAttributes[];
dsl?: string;
dsl: string;
}

View file

@ -114,14 +114,14 @@ interface AlertsFilterTimeFrameAttributes {
};
}
interface AlertsFilterAttributes {
export interface AlertsFilterAttributes {
query?: AlertsFilterQueryAttributes;
timeframe?: AlertsFilterTimeFrameAttributes;
}
export interface RuleActionAttributes {
uuid: string;
group: string;
group?: string;
actionRef: string;
actionTypeId: string;
params: SavedObjectAttributes;
@ -131,6 +131,7 @@ export interface RuleActionAttributes {
throttle: string | null;
};
alertsFilter?: AlertsFilterAttributes;
useAlertDataAsTemplate?: boolean;
}
type MappedParamsAttributes = SavedObjectAttributes & {

View file

@ -72,6 +72,7 @@ export {
} from './alerts_service';
export { sanitizeBulkErrorResponse, AlertsClientError } from './alerts_client';
export { getDataStreamAdapter } from './alerts_service/lib/data_stream_adapter';
export type { ConnectorAdapter } from './connector_adapters/types';
export const plugin = async (initContext: PluginInitializerContext) => {
const { AlertingPlugin } = await import('./plugin');

View file

@ -55,7 +55,7 @@ interface AlertOpts {
maintenanceWindowIds?: string[];
}
interface ActionOpts {
export interface ActionOpts {
id: string;
typeId: string;
alertId?: string;

View file

@ -0,0 +1,211 @@
/*
* 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 { ActionsClient } from '@kbn/actions-plugin/server';
import { actionsClientMock } from '@kbn/actions-plugin/server/mocks';
import { schema } from '@kbn/config-schema';
import { ConnectorAdapterRegistry } from '../connector_adapters/connector_adapter_registry';
import { ConnectorAdapter } from '../connector_adapters/types';
import { NormalizedSystemAction } from '../rules_client';
import { RuleSystemAction } from '../types';
import { validateSystemActions } from './validate_system_actions';
describe('validateSystemActionsWithoutRuleTypeId', () => {
const connectorAdapter: ConnectorAdapter = {
connectorTypeId: '.test',
ruleActionParamsSchema: schema.object({ foo: schema.string() }),
buildActionParams: jest.fn(),
};
let registry: ConnectorAdapterRegistry;
let actionsClient: jest.Mocked<ActionsClient>;
beforeEach(() => {
registry = new ConnectorAdapterRegistry();
actionsClient = actionsClientMock.create();
actionsClient.getBulk.mockResolvedValue([
{
id: 'system_action-id',
actionTypeId: '.test',
config: {},
isMissingSecrets: false,
name: 'system action connector',
isPreconfigured: false,
isDeprecated: false,
isSystemAction: true,
},
]);
});
it('should not validate with empty system actions', async () => {
const res = await validateSystemActions({
connectorAdapterRegistry: registry,
systemActions: [],
actionsClient,
});
expect(res).toBe(undefined);
expect(actionsClient.getBulk).not.toBeCalled();
expect(actionsClient.isSystemAction).not.toBeCalled();
});
it('should throw an error if the action is not a system action even if it is declared as one', async () => {
const systemActions: RuleSystemAction[] = [
{
id: 'not-exist',
uuid: '123',
params: { foo: 'test' },
actionTypeId: '.test',
},
];
registry.register(connectorAdapter);
actionsClient.isSystemAction.mockReturnValue(false);
await expect(() =>
validateSystemActions({
connectorAdapterRegistry: registry,
systemActions,
actionsClient,
})
).rejects.toThrowErrorMatchingInlineSnapshot(`"Action not-exist is not a system action"`);
});
it('should throw an error if the action is system action but is not returned from the actions client (getBulk)', async () => {
const systemActions: RuleSystemAction[] = [
{
id: 'not-exist',
uuid: '123',
params: { foo: 'test' },
actionTypeId: '.test',
},
];
registry.register(connectorAdapter);
actionsClient.isSystemAction.mockReturnValue(true);
await expect(() =>
validateSystemActions({
connectorAdapterRegistry: registry,
systemActions,
actionsClient,
})
).rejects.toThrowErrorMatchingInlineSnapshot(`"Action not-exist is not a system action"`);
});
it('should throw an error if the params are not valid', async () => {
const systemActions: RuleSystemAction[] = [
{
id: 'system_action-id',
uuid: '123',
params: { 'not-exist': 'test' },
actionTypeId: '.test',
},
];
registry.register(connectorAdapter);
actionsClient.isSystemAction.mockReturnValue(true);
await expect(() =>
validateSystemActions({
connectorAdapterRegistry: registry,
systemActions,
actionsClient,
})
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Invalid system action params. System action type: .test - [foo]: expected value of type [string] but got [undefined]"`
);
});
it('should throw an error if the same system action is being used', async () => {
const systemActions: RuleSystemAction[] = [
{
id: 'system_action-id',
uuid: '123',
params: { foo: 'test' },
actionTypeId: '.test',
},
{
id: 'system_action-id',
uuid: '123',
params: { foo: 'test' },
actionTypeId: '.test',
},
];
registry.register(connectorAdapter);
actionsClient.isSystemAction.mockReturnValue(false);
await expect(() =>
validateSystemActions({
connectorAdapterRegistry: registry,
systemActions,
actionsClient,
})
).rejects.toThrowErrorMatchingInlineSnapshot(`"Cannot use the same system action twice"`);
});
it('should call getBulk correctly', async () => {
const systemActions: Array<RuleSystemAction | NormalizedSystemAction> = [
{
id: 'system_action-id',
uuid: '123',
params: { foo: 'test' },
},
{
id: 'system_action-id-2',
uuid: '123',
params: { foo: 'test' },
actionTypeId: '.test',
},
];
actionsClient.getBulk.mockResolvedValue([
{
id: 'system_action-id',
actionTypeId: '.test',
config: {},
isMissingSecrets: false,
name: 'system action connector',
isPreconfigured: false,
isDeprecated: false,
isSystemAction: true,
},
{
id: 'system_action-id-2',
actionTypeId: '.test',
config: {},
isMissingSecrets: false,
name: 'system action connector 2',
isPreconfigured: false,
isDeprecated: false,
isSystemAction: true,
},
]);
registry.register(connectorAdapter);
actionsClient.isSystemAction.mockReturnValue(true);
const res = await validateSystemActions({
connectorAdapterRegistry: registry,
systemActions,
actionsClient,
});
expect(res).toBe(undefined);
expect(actionsClient.getBulk).toBeCalledWith({
ids: ['system_action-id', 'system_action-id-2'],
throwIfSystemAction: false,
});
});
});

View file

@ -0,0 +1,67 @@
/*
* 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 Boom from '@hapi/boom';
import { ActionsClient } from '@kbn/actions-plugin/server';
import { ConnectorAdapterRegistry } from '../connector_adapters/connector_adapter_registry';
import { bulkValidateConnectorAdapterActionParams } from '../connector_adapters/validate_rule_action_params';
import { NormalizedSystemAction } from '../rules_client';
import { RuleSystemAction } from '../types';
interface Params {
actionsClient: ActionsClient;
connectorAdapterRegistry: ConnectorAdapterRegistry;
systemActions: Array<RuleSystemAction | NormalizedSystemAction>;
}
export const validateSystemActions = async ({
actionsClient,
connectorAdapterRegistry,
systemActions = [],
}: Params) => {
if (systemActions.length === 0) {
return;
}
/**
* When updating or creating a rule the actions may not contain
* the actionTypeId. We need to getBulk using the
* actionsClient to get the actionTypeId of each action.
* The actionTypeId is needed to get the schema of
* the action params using the connector adapter registry
*/
const actionIds: Set<string> = new Set(systemActions.map((action) => action.id));
if (actionIds.size !== systemActions.length) {
throw Boom.badRequest('Cannot use the same system action twice');
}
const actionResults = await actionsClient.getBulk({
ids: Array.from(actionIds),
throwIfSystemAction: false,
});
const systemActionsWithActionTypeId: RuleSystemAction[] = [];
for (const systemAction of systemActions) {
const isSystemAction = actionsClient.isSystemAction(systemAction.id);
const foundAction = actionResults.find((actionRes) => actionRes.id === systemAction.id);
if (!isSystemAction || !foundAction) {
throw Boom.badRequest(`Action ${systemAction.id} is not a system action`);
}
systemActionsWithActionTypeId.push({
...systemAction,
actionTypeId: foundAction.actionTypeId,
});
}
bulkValidateConnectorAdapterActionParams({
connectorAdapterRegistry,
actions: systemActionsWithActionTypeId,
});
};

View file

@ -36,6 +36,7 @@ const createSetupMock = () => {
getContextInitializationPromise: jest.fn(),
},
getDataStreamAdapter: jest.fn(),
registerConnectorAdapter: jest.fn(),
};
return mock;
};

View file

@ -27,6 +27,8 @@ import {
PluginSetup as DataPluginSetup,
} from '@kbn/data-plugin/server';
import { spacesMock } from '@kbn/spaces-plugin/server/mocks';
import { schema } from '@kbn/config-schema';
import { serverlessPluginMock } from '@kbn/serverless/server/mocks';
import { AlertsService } from './alerts_service/alerts_service';
import { alertsServiceMock } from './alerts_service/alerts_service.mock';
@ -37,7 +39,6 @@ jest.mock('./alerts_service/alerts_service', () => ({
import { SharePluginStart } from '@kbn/share-plugin/server';
import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks';
import { generateAlertingConfig } from './test_utils';
import { serverlessPluginMock } from '@kbn/serverless/server/mocks';
const sampleRuleType: RuleType<never, never, {}, never, never, 'default', 'recovered', {}> = {
id: 'test',
@ -240,6 +241,32 @@ describe('Alerting Plugin', () => {
expect(ruleType.cancelAlertsOnRuleTimeout).toBe(false);
});
});
describe('registerConnectorAdapter()', () => {
let setup: PluginSetupContract;
beforeEach(async () => {
const context = coreMock.createPluginInitializerContext<AlertingConfig>(
generateAlertingConfig()
);
plugin = new AlertingPlugin(context);
setup = await plugin.setup(setupMocks, mockPlugins);
});
it('should register a connector adapter', () => {
const adapter = {
connectorTypeId: '.test',
ruleActionParamsSchema: schema.object({}),
buildActionParams: jest.fn(),
};
setup.registerConnectorAdapter(adapter);
// @ts-expect-error: private properties cannot be accessed
expect(plugin.connectorAdapterRegistry.get('.test')).toEqual(adapter);
});
});
});
describe('start()', () => {

View file

@ -99,6 +99,8 @@ import {
} from './alerts_service';
import { getRulesSettingsFeature } from './rules_settings_feature';
import { maintenanceWindowFeature } from './maintenance_window_feature';
import { ConnectorAdapterRegistry } from './connector_adapters/connector_adapter_registry';
import { ConnectorAdapter, ConnectorAdapterParams } from './connector_adapters/types';
import { DataStreamAdapter, getDataStreamAdapter } from './alerts_service/lib/data_stream_adapter';
import { createGetAlertIndicesAliasFn, GetAlertIndicesAlias } from './lib';
@ -118,6 +120,12 @@ export const LEGACY_EVENT_LOG_ACTIONS = {
};
export interface PluginSetupContract {
registerConnectorAdapter<
RuleActionParams extends ConnectorAdapterParams = ConnectorAdapterParams,
ConnectorParams extends ConnectorAdapterParams = ConnectorAdapterParams
>(
adapter: ConnectorAdapter<RuleActionParams, ConnectorParams>
): void;
registerType<
Params extends RuleTypeParams = RuleTypeParams,
ExtractedParams extends RuleTypeParams = RuleTypeParams,
@ -216,6 +224,7 @@ export class AlertingPlugin {
private pluginStop$: Subject<void>;
private dataStreamAdapter?: DataStreamAdapter;
private nodeRoles: PluginInitializerContext['node']['roles'];
private readonly connectorAdapterRegistry = new ConnectorAdapterRegistry();
constructor(initializerContext: PluginInitializerContext) {
this.config = initializerContext.config.get();
@ -378,6 +387,14 @@ export class AlertingPlugin {
});
return {
registerConnectorAdapter: <
RuleActionParams extends ConnectorAdapterParams = ConnectorAdapterParams,
ConnectorParams extends ConnectorAdapterParams = ConnectorAdapterParams
>(
adapter: ConnectorAdapter<RuleActionParams, ConnectorParams>
) => {
this.connectorAdapterRegistry.register(adapter);
},
registerType: <
Params extends RuleTypeParams = never,
ExtractedParams extends RuleTypeParams = never,
@ -507,6 +524,7 @@ export class AlertingPlugin {
maxScheduledPerMinute: this.config.rules.maxScheduledPerMinute,
getAlertIndicesAlias: createGetAlertIndicesAliasFn(this.ruleTypeRegistry!),
alertsService: this.alertsService,
connectorAdapterRegistry: this.connectorAdapterRegistry,
uiSettings: core.uiSettings,
});
@ -573,6 +591,7 @@ export class AlertingPlugin {
usageCounter: this.usageCounter,
getRulesSettingsClientWithRequest,
getMaintenanceWindowClientWithRequest,
connectorAdapterRegistry: this.connectorAdapterRegistry,
});
this.eventLogService!.registerSavedObjectProvider(RULE_SAVED_OBJECT_TYPE, (request) => {

View file

@ -9,6 +9,8 @@ import { KibanaRequest, KibanaResponseFactory } from '@kbn/core/server';
import { identity } from 'lodash';
import type { MethodKeysOf } from '@kbn/utility-types';
import { httpServerMock } from '@kbn/core/server/mocks';
import { actionsClientMock } from '@kbn/actions-plugin/server/mocks';
import type { ActionsClientMock } from '@kbn/actions-plugin/server/mocks';
import { rulesClientMock, RulesClientMock } from '../rules_client.mock';
import { rulesSettingsClientMock, RulesSettingsClientMock } from '../rules_settings_client.mock';
import {
@ -21,6 +23,7 @@ import type { AlertingRequestHandlerContext } from '../types';
export function mockHandlerArguments(
{
rulesClient = rulesClientMock.create(),
actionsClient = actionsClientMock.create(),
rulesSettingsClient = rulesSettingsClientMock.create(),
maintenanceWindowClient = maintenanceWindowClientMock.create(),
listTypes: listTypesRes = [],
@ -28,6 +31,7 @@ export function mockHandlerArguments(
areApiKeysEnabled,
}: {
rulesClient?: RulesClientMock;
actionsClient?: ActionsClientMock;
rulesSettingsClient?: RulesSettingsClientMock;
maintenanceWindowClient?: MaintenanceWindowClientMock;
listTypes?: RuleType[];
@ -43,6 +47,10 @@ export function mockHandlerArguments(
KibanaResponseFactory
] {
const listTypes = jest.fn(() => listTypesRes);
const actionsClientMocked = actionsClient || actionsClientMock.create();
actionsClient.isSystemAction.mockImplementation((id) => id === 'system_action-id');
return [
{
alerting: {
@ -59,6 +67,11 @@ export function mockHandlerArguments(
getFrameworkHealth,
areApiKeysEnabled: areApiKeysEnabled ? areApiKeysEnabled : () => Promise.resolve(true),
},
actions: {
getActionsClient() {
return actionsClientMocked;
},
},
} as unknown as AlertingRequestHandlerContext,
request as KibanaRequest<unknown, unknown, unknown>,
mockResponseFactory(response),

View file

@ -6,13 +6,15 @@
*/
import { httpServiceMock } from '@kbn/core/server/mocks';
import { actionsClientMock } from '@kbn/actions-plugin/server/mocks';
import { bulkEnableRulesRoute } from './bulk_enable_rules';
import { licenseStateMock } from '../lib/license_state.mock';
import { mockHandlerArguments } from './_mock_handler_arguments';
import { rulesClientMock } from '../rules_client.mock';
import { RuleTypeDisabledError } from '../lib/errors/rule_type_disabled';
import { verifyApiAccess } from '../lib/license_api_access';
import { RuleAction, RuleSystemAction } from '../types';
import { Rule } from '../application/rule/types';
const rulesClient = rulesClientMock.create();
@ -123,4 +125,121 @@ describe('bulkEnableRulesRoute', () => {
expect(res.forbidden).toHaveBeenCalledWith({ body: { message: 'Fail' } });
});
describe('actions', () => {
const mockedRule: Rule<{}> = {
id: '1',
alertTypeId: '1',
schedule: { interval: '10s' },
params: {
bar: true,
},
createdAt: new Date(),
updatedAt: new Date(),
actions: [
{
group: 'default',
id: '2',
actionTypeId: 'test',
params: {
foo: true,
},
uuid: '123-456',
},
],
consumer: 'bar',
name: 'abc',
tags: ['foo'],
enabled: true,
muteAll: false,
notifyWhen: 'onActionGroupChange',
createdBy: '',
updatedBy: '',
apiKeyOwner: '',
throttle: '30s',
mutedInstanceIds: [],
executionStatus: {
status: 'unknown',
lastExecutionDate: new Date('2020-08-20T19:23:38Z'),
},
revision: 0,
};
const action: RuleAction = {
actionTypeId: 'test',
group: 'default',
id: '2',
params: {
foo: true,
},
uuid: '123-456',
};
const systemAction: RuleSystemAction = {
actionTypeId: 'test-2',
id: 'system_action-id',
params: {
foo: true,
},
uuid: '123-456',
};
const mockedRules: Array<Rule<{}>> = [
{
...mockedRule,
actions: [action],
systemActions: [systemAction],
},
];
const bulkEnableActionsResult = {
rules: mockedRules,
errors: [],
total: 1,
taskIdsFailedToBeEnabled: [],
};
it('should merge actions and systemActions correctly before sending the response', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
const actionsClient = actionsClientMock.create();
actionsClient.isSystemAction.mockImplementation((id: string) => id === 'system_action-id');
bulkEnableRulesRoute({ router, licenseState });
const [_, handler] = router.patch.mock.calls[0];
rulesClient.bulkEnableRules.mockResolvedValueOnce(bulkEnableActionsResult);
const [context, req, res] = mockHandlerArguments(
{ rulesClient, actionsClient },
{
body: bulkEnableRequest,
},
['ok']
);
const routeRes = await handler(context, req, res);
// @ts-expect-error: body exists
expect(routeRes.body.rules[0].actions).toEqual([
{
actionTypeId: 'test',
group: 'default',
id: '2',
params: {
foo: true,
},
uuid: '123-456',
},
{
actionTypeId: 'test-2',
id: 'system_action-id',
params: {
foo: true,
},
uuid: '123-456',
},
]);
});
});
});

View file

@ -35,8 +35,19 @@ export const bulkEnableRulesRoute = ({
const { filter, ids } = req.body;
try {
const result = await rulesClient.bulkEnableRules({ filter, ids });
return res.ok({ body: result });
const bulkEnableResults = await rulesClient.bulkEnableRules({ filter, ids });
const resultBody = {
body: {
...bulkEnableResults,
// TODO We need to fix this API to return snake case like every other API
rules: bulkEnableResults.rules.map(({ actions, systemActions, ...rule }) => {
return { ...rule, actions: [...actions, ...(systemActions ?? [])] };
}),
},
};
return res.ok(resultBody);
} catch (e) {
if (e instanceof RuleTypeDisabledError) {
return e.sendResponse(res);

View file

@ -13,8 +13,7 @@ import { mockHandlerArguments } from './_mock_handler_arguments';
import { rulesClientMock } from '../rules_client.mock';
import { RuleTypeDisabledError } from '../lib/errors/rule_type_disabled';
import { cloneRuleRoute } from './clone_rule';
import { SanitizedRule } from '../types';
import { AsApiContract } from './lib';
import { RuleAction, RuleSystemAction, SanitizedRule } from '../types';
const rulesClient = rulesClientMock.create();
jest.mock('../lib/license_api_access', () => ({
@ -29,6 +28,25 @@ describe('cloneRuleRoute', () => {
const createdAt = new Date();
const updatedAt = new Date();
const action: RuleAction = {
actionTypeId: 'test',
group: 'default',
id: '2',
params: {
foo: true,
},
uuid: '123-456',
};
const systemAction: RuleSystemAction = {
actionTypeId: 'test-2',
id: 'system_action-id',
params: {
foo: true,
},
uuid: '123-456',
};
const mockedRule: SanitizedRule<{ bar: boolean }> = {
alertTypeId: '1',
consumer: 'bar',
@ -39,17 +57,7 @@ describe('cloneRuleRoute', () => {
bar: true,
},
throttle: '30s',
actions: [
{
actionTypeId: 'test',
group: 'default',
id: '2',
params: {
foo: true,
},
uuid: '123-456',
},
],
actions: [action],
enabled: true,
muteAll: false,
createdBy: '',
@ -80,7 +88,7 @@ describe('cloneRuleRoute', () => {
],
};
const cloneResult: AsApiContract<SanitizedRule<{ bar: boolean }>> = {
const cloneResult = {
...ruleToClone,
mute_all: mockedRule.muteAll,
created_by: mockedRule.createdBy,
@ -214,4 +222,54 @@ describe('cloneRuleRoute', () => {
expect(res.forbidden).toHaveBeenCalledWith({ body: { message: 'Fail' } });
});
it('transforms the system actions in the response of the rules client correctly', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
cloneRuleRoute(router, licenseState);
const [_, handler] = router.post.mock.calls[0];
rulesClient.clone.mockResolvedValueOnce({
...mockedRule,
actions: [action],
systemActions: [systemAction],
});
const [context, req, res] = mockHandlerArguments(
{ rulesClient },
{
params: {
id: '1',
},
},
['ok']
);
const routeRes = await handler(context, req, res);
// @ts-expect-error: body exists
expect(routeRes.body.systemActions).toBeUndefined();
// @ts-expect-error: body exists
expect(routeRes.body.actions).toEqual([
{
connector_type_id: 'test',
group: 'default',
id: '2',
params: {
foo: true,
},
uuid: '123-456',
},
{
connector_type_id: 'test-2',
id: 'system_action-id',
params: {
foo: true,
},
uuid: '123-456',
},
]);
});
});

View file

@ -8,27 +8,23 @@
import { schema } from '@kbn/config-schema';
import { IRouter } from '@kbn/core/server';
import { ILicenseState, RuleTypeDisabledError } from '../lib';
import {
verifyAccessAndContext,
RewriteResponseCase,
handleDisabledApiKeysError,
rewriteRuleLastRun,
rewriteActionsRes,
} from './lib';
import { verifyAccessAndContext, handleDisabledApiKeysError, rewriteRuleLastRun } from './lib';
import {
RuleTypeParams,
AlertingRequestHandlerContext,
INTERNAL_BASE_ALERTING_API_PATH,
PartialRule,
} from '../types';
import { transformRuleActions } from './rule/transforms';
const paramSchema = schema.object({
id: schema.string(),
newId: schema.maybe(schema.string()),
});
const rewriteBodyRes: RewriteResponseCase<PartialRule<RuleTypeParams>> = ({
const rewriteBodyRes = ({
actions,
systemActions,
alertTypeId,
scheduledTaskId,
createdBy,
@ -46,7 +42,7 @@ const rewriteBodyRes: RewriteResponseCase<PartialRule<RuleTypeParams>> = ({
lastRun,
nextRun,
...rest
}) => ({
}: PartialRule<RuleTypeParams>) => ({
...rest,
api_key_owner: apiKeyOwner,
created_by: createdBy,
@ -71,7 +67,7 @@ const rewriteBodyRes: RewriteResponseCase<PartialRule<RuleTypeParams>> = ({
: {}),
...(actions
? {
actions: rewriteActionsRes(actions),
actions: transformRuleActions(actions, systemActions),
}
: {}),
...(lastRun ? { last_run: rewriteRuleLastRun(lastRun) } : {}),

View file

@ -97,6 +97,219 @@ describe('findRulesRoute', () => {
});
});
it('should rewrite the rule and actions correctly', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
findRulesRoute(router, licenseState);
const [config, handler] = router.get.mock.calls[0];
expect(config.path).toMatchInlineSnapshot(`"/api/alerting/rules/_find"`);
const findResult = {
page: 1,
perPage: 1,
total: 0,
data: [
{
id: '3d534c70-582b-11ec-8995-2b1578a3bc5d',
notifyWhen: 'onActiveAlert' as const,
alertTypeId: '.index-threshold',
name: 'stressing index-threshold 37/200',
consumer: 'alerts',
tags: [],
enabled: true,
throttle: null,
apiKey: null,
apiKeyOwner: '2889684073',
createdBy: 'elastic',
updatedBy: '2889684073',
muteAll: false,
mutedInstanceIds: [],
schedule: {
interval: '1s',
},
actions: [
{
actionTypeId: '.server-log',
params: {
message: 'alert 37: {{context.message}}',
},
group: 'threshold met',
id: '3619a0d0-582b-11ec-8995-2b1578a3bc5d',
uuid: '123-456',
},
],
systemActions: [
{ actionTypeId: '.test', id: 'system_action-id', params: {}, uuid: '789' },
],
params: { x: 42 },
updatedAt: '2024-03-21T13:15:00.498Z',
createdAt: '2024-03-21T13:15:00.498Z',
scheduledTaskId: '52125fb0-5895-11ec-ae69-bb65d1a71b72',
executionStatus: {
status: 'ok' as const,
lastExecutionDate: '2024-03-21T13:15:00.498Z',
lastDuration: 1194,
},
revision: 0,
},
],
};
// @ts-expect-error: TS complains about group being undefined in the system action
rulesClient.find.mockResolvedValueOnce(findResult);
const [context, req, res] = mockHandlerArguments(
{ rulesClient },
{
query: {
per_page: 1,
page: 1,
default_search_operator: 'OR',
},
},
['ok']
);
expect(await handler(context, req, res)).toMatchInlineSnapshot(`
Object {
"body": Object {
"data": Array [
Object {
"actions": Array [
Object {
"connector_type_id": ".server-log",
"group": "threshold met",
"id": "3619a0d0-582b-11ec-8995-2b1578a3bc5d",
"params": Object {
"message": "alert 37: {{context.message}}",
},
"uuid": "123-456",
},
Object {
"connector_type_id": ".test",
"id": "system_action-id",
"params": Object {},
"uuid": "789",
},
],
"apiKey": null,
"api_key_owner": "2889684073",
"consumer": "alerts",
"created_at": "2024-03-21T13:15:00.498Z",
"created_by": "elastic",
"enabled": true,
"execution_status": Object {
"last_duration": 1194,
"last_execution_date": "2024-03-21T13:15:00.498Z",
"status": "ok",
},
"id": "3d534c70-582b-11ec-8995-2b1578a3bc5d",
"mute_all": false,
"muted_alert_ids": Array [],
"name": "stressing index-threshold 37/200",
"notify_when": "onActiveAlert",
"params": Object {
"x": 42,
},
"revision": 0,
"rule_type_id": ".index-threshold",
"schedule": Object {
"interval": "1s",
},
"scheduled_task_id": "52125fb0-5895-11ec-ae69-bb65d1a71b72",
"snooze_schedule": undefined,
"tags": Array [],
"throttle": null,
"updated_at": "2024-03-21T13:15:00.498Z",
"updated_by": "2889684073",
},
],
"page": 1,
"per_page": 1,
"total": 0,
},
}
`);
expect(rulesClient.find).toHaveBeenCalledTimes(1);
expect(rulesClient.find.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"excludeFromPublicApi": true,
"includeSnoozeData": true,
"options": Object {
"defaultSearchOperator": "OR",
"filterConsumers": undefined,
"page": 1,
"perPage": 1,
},
},
]
`);
expect(res.ok).toHaveBeenCalledWith({
body: {
page: 1,
per_page: 1,
total: 0,
data: [
{
actions: [
{
connector_type_id: '.server-log',
group: 'threshold met',
id: '3619a0d0-582b-11ec-8995-2b1578a3bc5d',
params: {
message: 'alert 37: {{context.message}}',
},
uuid: '123-456',
},
{
connector_type_id: '.test',
id: 'system_action-id',
params: {},
uuid: '789',
},
],
apiKey: null,
api_key_owner: '2889684073',
consumer: 'alerts',
created_at: '2024-03-21T13:15:00.498Z',
created_by: 'elastic',
enabled: true,
execution_status: {
last_duration: 1194,
last_execution_date: '2024-03-21T13:15:00.498Z',
status: 'ok',
},
id: '3d534c70-582b-11ec-8995-2b1578a3bc5d',
mute_all: false,
muted_alert_ids: [],
name: 'stressing index-threshold 37/200',
notify_when: 'onActiveAlert',
params: {
x: 42,
},
revision: 0,
rule_type_id: '.index-threshold',
schedule: {
interval: '1s',
},
scheduled_task_id: '52125fb0-5895-11ec-ae69-bb65d1a71b72',
snooze_schedule: undefined,
tags: [],
throttle: null,
updated_at: '2024-03-21T13:15:00.498Z',
updated_by: '2889684073',
},
],
},
});
});
it('ensures the license allows finding rules', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();

View file

@ -10,12 +10,7 @@ import { UsageCounter } from '@kbn/usage-collection-plugin/server';
import { schema } from '@kbn/config-schema';
import { ILicenseState } from '../lib';
import { FindOptions, FindResult } from '../rules_client';
import {
RewriteRequestCase,
RewriteResponseCase,
verifyAccessAndContext,
rewriteRule,
} from './lib';
import { RewriteRequestCase, verifyAccessAndContext, rewriteRule } from './lib';
import {
RuleTypeParams,
AlertingRequestHandlerContext,
@ -69,11 +64,7 @@ const rewriteQueryReq: RewriteRequestCase<FindOptions> = ({
...(hasReference ? { hasReference } : {}),
...(searchFields ? { searchFields } : {}),
});
const rewriteBodyRes: RewriteResponseCase<FindResult<RuleTypeParams>> = ({
perPage,
data,
...restOfResult
}) => {
const rewriteBodyRes = ({ perPage, data, ...restOfResult }: FindResult<RuleTypeParams>) => {
return {
...restOfResult,
per_page: perPage,

View file

@ -12,8 +12,7 @@ import { licenseStateMock } from '../lib/license_state.mock';
import { verifyApiAccess } from '../lib/license_api_access';
import { mockHandlerArguments } from './_mock_handler_arguments';
import { rulesClientMock } from '../rules_client.mock';
import { SanitizedRule } from '../types';
import { AsApiContract } from './lib';
import { RuleAction, RuleSystemAction, SanitizedRule } from '../types';
const rulesClient = rulesClientMock.create();
jest.mock('../lib/license_api_access', () => ({
@ -25,6 +24,37 @@ beforeEach(() => {
});
describe('getRuleRoute', () => {
const action: RuleAction = {
group: 'default',
id: '2',
actionTypeId: 'test',
params: {
foo: true,
},
uuid: '123-456',
alertsFilter: {
query: {
kql: 'name:test',
dsl: '{"must": {"term": { "name": "test" }}}',
filters: [],
},
timeframe: {
days: [1],
hours: { start: '08:00', end: '17:00' },
timezone: 'UTC',
},
},
};
const systemAction: RuleSystemAction = {
actionTypeId: 'test-2',
id: 'system_action-id',
params: {
foo: true,
},
uuid: '123-456',
};
const mockedAlert: SanitizedRule<{
bar: boolean;
}> = {
@ -36,30 +66,7 @@ describe('getRuleRoute', () => {
},
createdAt: new Date(),
updatedAt: new Date(),
actions: [
{
group: 'default',
id: '2',
actionTypeId: 'test',
params: {
foo: true,
},
uuid: '123-456',
alertsFilter: {
query: {
kql: 'name:test',
// @ts-expect-error upgrade typescript v4.9.5
dsl: '{"must": {"term": { "name": "test" }}}',
filters: [],
},
timeframe: {
days: [1],
hours: { start: '08:00', end: '17:00' },
timezone: 'UTC',
},
},
},
],
actions: [action],
consumer: 'bar',
name: 'abc',
tags: ['foo'],
@ -78,7 +85,8 @@ describe('getRuleRoute', () => {
revision: 0,
};
const getResult: AsApiContract<SanitizedRule<{ bar: boolean }>> = {
const mockedAction0 = mockedAlert.actions[0];
const getResult = {
...pick(mockedAlert, 'consumer', 'name', 'schedule', 'tags', 'params', 'throttle', 'enabled'),
rule_type_id: mockedAlert.alertTypeId,
notify_when: mockedAlert.notifyWhen,
@ -97,12 +105,12 @@ describe('getRuleRoute', () => {
},
actions: [
{
group: mockedAlert.actions[0].group,
id: mockedAlert.actions[0].id,
params: mockedAlert.actions[0].params,
connector_type_id: mockedAlert.actions[0].actionTypeId,
uuid: mockedAlert.actions[0].uuid,
alerts_filter: mockedAlert.actions[0].alertsFilter,
group: mockedAction0.group,
id: mockedAction0.id,
params: mockedAction0.params,
connector_type_id: mockedAction0.actionTypeId,
uuid: mockedAction0.uuid,
alerts_filter: mockedAction0.alertsFilter,
},
],
};
@ -184,4 +192,66 @@ describe('getRuleRoute', () => {
expect(verifyApiAccess).toHaveBeenCalledWith(licenseState);
});
it('transforms the system actions in the response of the rules client correctly', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
getRuleRoute(router, licenseState);
const [_, handler] = router.get.mock.calls[0];
rulesClient.get.mockResolvedValueOnce({
...mockedAlert,
actions: [action],
systemActions: [systemAction],
});
const [context, req, res] = mockHandlerArguments(
{ rulesClient },
{
params: { id: '1' },
},
['ok']
);
const routeRes = await handler(context, req, res);
// @ts-expect-error: body exists
expect(routeRes.body.systemActions).toBeUndefined();
// @ts-expect-error: body exists
expect(routeRes.body.actions).toEqual([
{
alerts_filter: {
query: {
dsl: '{"must": {"term": { "name": "test" }}}',
filters: [],
kql: 'name:test',
},
timeframe: {
days: [1],
hours: {
end: '17:00',
start: '08:00',
},
timezone: 'UTC',
},
},
connector_type_id: 'test',
group: 'default',
id: '2',
params: {
foo: true,
},
uuid: '123-456',
},
{
connector_type_id: 'test-2',
id: 'system_action-id',
params: {
foo: true,
},
uuid: '123-456',
},
]);
});
});

View file

@ -9,12 +9,7 @@ import { omit } from 'lodash';
import { schema } from '@kbn/config-schema';
import { IRouter } from '@kbn/core/server';
import { ILicenseState } from '../lib';
import {
verifyAccessAndContext,
RewriteResponseCase,
rewriteRuleLastRun,
rewriteActionsRes,
} from './lib';
import { verifyAccessAndContext, rewriteRuleLastRun } from './lib';
import {
RuleTypeParams,
AlertingRequestHandlerContext,
@ -22,12 +17,13 @@ import {
INTERNAL_BASE_ALERTING_API_PATH,
SanitizedRule,
} from '../types';
import { transformRuleActions } from './rule/transforms';
const paramSchema = schema.object({
id: schema.string(),
});
const rewriteBodyRes: RewriteResponseCase<SanitizedRule<RuleTypeParams>> = ({
const rewriteBodyRes = ({
alertTypeId,
createdBy,
updatedBy,
@ -40,6 +36,7 @@ const rewriteBodyRes: RewriteResponseCase<SanitizedRule<RuleTypeParams>> = ({
mutedInstanceIds,
executionStatus,
actions,
systemActions,
scheduledTaskId,
snoozeSchedule,
isSnoozedUntil,
@ -47,7 +44,7 @@ const rewriteBodyRes: RewriteResponseCase<SanitizedRule<RuleTypeParams>> = ({
nextRun,
viewInAppRelativeUrl,
...rest
}) => ({
}: SanitizedRule<RuleTypeParams>) => ({
...rest,
rule_type_id: alertTypeId,
created_by: createdBy,
@ -66,7 +63,7 @@ const rewriteBodyRes: RewriteResponseCase<SanitizedRule<RuleTypeParams>> = ({
last_execution_date: executionStatus.lastExecutionDate,
last_duration: executionStatus.lastDuration,
},
actions: rewriteActionsRes(actions),
actions: transformRuleActions(actions, systemActions),
...(lastRun ? { last_run: rewriteRuleLastRun(lastRun) } : {}),
...(nextRun ? { next_run: nextRun } : {}),
...(viewInAppRelativeUrl ? { view_in_app_relative_url: viewInAppRelativeUrl } : {}),

View file

@ -12,7 +12,7 @@ import { licenseStateMock } from '../../lib/license_state.mock';
import { verifyApiAccess } from '../../lib/license_api_access';
import { mockHandlerArguments } from '../_mock_handler_arguments';
import { rulesClientMock } from '../../rules_client.mock';
import { Rule } from '../../../common/rule';
import { Rule, RuleSystemAction } from '../../../common/rule';
import { RuleTypeDisabledError } from '../../lib/errors/rule_type_disabled';
import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks';
import { trackLegacyRouteUsage } from '../../lib/track_legacy_route_usage';
@ -57,6 +57,15 @@ describe('createAlertRoute', () => {
],
};
const systemAction: RuleSystemAction = {
actionTypeId: 'test-2',
id: 'system_action-id',
params: {
foo: true,
},
uuid: '123-456',
};
const createResult: Rule<{ bar: boolean }> = {
...mockedAlert,
enabled: true,
@ -460,8 +469,81 @@ describe('createAlertRoute', () => {
usageCounter: mockUsageCounter,
});
const [, handler] = router.post.mock.calls[0];
rulesClient.create.mockResolvedValueOnce(createResult);
const [context, req, res] = mockHandlerArguments({ rulesClient }, {}, ['ok']);
await handler(context, req, res);
expect(trackLegacyRouteUsage).toHaveBeenCalledWith('create', mockUsageCounter);
});
it('does not return system actions', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup({ canEncrypt: true });
const mockUsageCountersSetup = usageCountersServiceMock.createSetupContract();
const mockUsageCounter = mockUsageCountersSetup.createUsageCounter('test');
createAlertRoute({
router,
licenseState,
encryptedSavedObjects,
usageCounter: mockUsageCounter,
});
const [config, handler] = router.post.mock.calls[0];
expect(config.path).toMatchInlineSnapshot(`"/api/alerts/alert/{id?}"`);
rulesClient.create.mockResolvedValueOnce({ ...createResult, systemActions: [systemAction] });
const [context, req, res] = mockHandlerArguments(
{ rulesClient },
{
body: mockedAlert,
},
['ok']
);
expect(await handler(context, req, res)).toEqual({ body: createResult });
expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled();
expect(rulesClient.create).toHaveBeenCalledTimes(1);
expect(rulesClient.create.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"data": Object {
"actions": Array [
Object {
"group": "default",
"id": "2",
"params": Object {
"foo": true,
},
},
],
"alertTypeId": "1",
"consumer": "bar",
"name": "abc",
"notifyWhen": "onActionGroupChange",
"params": Object {
"bar": true,
},
"schedule": Object {
"interval": "10s",
},
"tags": Array [
"foo",
],
"throttle": "30s",
},
"options": Object {
"id": undefined,
},
},
]
`);
expect(res.ok).toHaveBeenCalledWith({
body: createResult,
});
});
});

View file

@ -78,10 +78,11 @@ export const createAlertRoute = ({ router, licenseState, usageCounter }: RouteOp
});
try {
const alertRes: SanitizedRule<RuleTypeParams> = await rulesClient.create<RuleTypeParams>({
data: { ...alert, notifyWhen },
options: { id: params?.id },
});
const { systemActions, ...alertRes }: SanitizedRule<RuleTypeParams> =
await rulesClient.create<RuleTypeParams>({
data: { ...alert, notifyWhen },
options: { id: params?.id },
});
return res.ok({
body: alertRes,
});

View file

@ -4,6 +4,8 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { omit } from 'lodash';
import { usageCountersServiceMock } from '@kbn/usage-collection-plugin/server/usage_counters/usage_counters_service.mock';
import { findAlertRoute } from './find';
import { httpServiceMock } from '@kbn/core/server/mocks';
@ -160,6 +162,13 @@ describe('findAlertRoute', () => {
findAlertRoute(router, licenseState, mockUsageCounter);
const [, handler] = router.get.mock.calls[0];
const findResult = {
page: 1,
perPage: 1,
total: 0,
data: [],
};
rulesClient.find.mockResolvedValueOnce(findResult);
const [context, req, res] = mockHandlerArguments({ rulesClient }, { params: {}, query: {} }, [
'ok',
]);
@ -175,6 +184,14 @@ describe('findAlertRoute', () => {
findAlertRoute(router, licenseState, mockUsageCounter);
const [, handler] = router.get.mock.calls[0];
const findResult = {
page: 1,
perPage: 1,
total: 0,
data: [],
};
rulesClient.find.mockResolvedValueOnce(findResult);
const [context, req, res] = mockHandlerArguments(
{ rulesClient },
{
@ -204,6 +221,13 @@ describe('findAlertRoute', () => {
findAlertRoute(router, licenseState, mockUsageCounter);
const [, handler] = router.get.mock.calls[0];
const findResult = {
page: 1,
perPage: 1,
total: 0,
data: [],
};
rulesClient.find.mockResolvedValueOnce(findResult);
const [context, req, res] = mockHandlerArguments(
{ rulesClient },
{
@ -221,4 +245,153 @@ describe('findAlertRoute', () => {
incrementBy: 1,
});
});
it('does not return system actions', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
findAlertRoute(router, licenseState);
const [config, handler] = router.get.mock.calls[0];
expect(config.path).toMatchInlineSnapshot(`"/api/alerts/_find"`);
const findResult = {
page: 1,
perPage: 1,
total: 0,
data: [
{
id: '3d534c70-582b-11ec-8995-2b1578a3bc5d',
notifyWhen: 'onActiveAlert' as const,
alertTypeId: '.index-threshold',
name: 'stressing index-threshold 37/200',
consumer: 'alerts',
tags: [],
enabled: true,
throttle: null,
apiKey: null,
apiKeyOwner: '2889684073',
createdBy: 'elastic',
updatedBy: '2889684073',
muteAll: false,
mutedInstanceIds: [],
schedule: {
interval: '1s',
},
actions: [
{
actionTypeId: '.server-log',
params: {
message: 'alert 37: {{context.message}}',
},
group: 'threshold met',
id: '3619a0d0-582b-11ec-8995-2b1578a3bc5d',
uuid: '123-456',
},
],
systemActions: [
{ actionTypeId: '.test', id: 'system_action-id', params: {}, uuid: '789' },
],
params: { x: 42 },
updatedAt: '2024-03-21T13:15:00.498Z',
createdAt: '2024-03-21T13:15:00.498Z',
scheduledTaskId: '52125fb0-5895-11ec-ae69-bb65d1a71b72',
executionStatus: {
status: 'ok' as const,
lastExecutionDate: '2024-03-21T13:15:00.498Z',
lastDuration: 1194,
},
revision: 0,
},
],
};
// @ts-expect-error: TS complains about dates being string and not a Date object
rulesClient.find.mockResolvedValueOnce(findResult);
const [context, req, res] = mockHandlerArguments(
{ rulesClient },
{
query: {
per_page: 1,
page: 1,
default_search_operator: 'OR',
},
},
['ok']
);
expect(await handler(context, req, res)).toMatchInlineSnapshot(`
Object {
"body": Object {
"data": Array [
Object {
"actions": Array [
Object {
"actionTypeId": ".server-log",
"group": "threshold met",
"id": "3619a0d0-582b-11ec-8995-2b1578a3bc5d",
"params": Object {
"message": "alert 37: {{context.message}}",
},
"uuid": "123-456",
},
],
"alertTypeId": ".index-threshold",
"apiKey": null,
"apiKeyOwner": "2889684073",
"consumer": "alerts",
"createdAt": "2024-03-21T13:15:00.498Z",
"createdBy": "elastic",
"enabled": true,
"executionStatus": Object {
"lastDuration": 1194,
"lastExecutionDate": "2024-03-21T13:15:00.498Z",
"status": "ok",
},
"id": "3d534c70-582b-11ec-8995-2b1578a3bc5d",
"muteAll": false,
"mutedInstanceIds": Array [],
"name": "stressing index-threshold 37/200",
"notifyWhen": "onActiveAlert",
"params": Object {
"x": 42,
},
"revision": 0,
"schedule": Object {
"interval": "1s",
},
"scheduledTaskId": "52125fb0-5895-11ec-ae69-bb65d1a71b72",
"tags": Array [],
"throttle": null,
"updatedAt": "2024-03-21T13:15:00.498Z",
"updatedBy": "2889684073",
},
],
"page": 1,
"perPage": 1,
"total": 0,
},
}
`);
expect(rulesClient.find).toHaveBeenCalledTimes(1);
expect(rulesClient.find.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"excludeFromPublicApi": true,
"options": Object {
"defaultSearchOperator": "OR",
"page": 1,
"perPage": 1,
},
},
]
`);
expect(res.ok).toHaveBeenCalledWith({
body: omit(findResult, 'data[0].systemActions'),
});
});
});

View file

@ -99,7 +99,10 @@ export const findAlertRoute = (
const findResult = await rulesClient.find({ options, excludeFromPublicApi: true });
return res.ok({
body: findResult,
body: {
...findResult,
data: findResult.data.map(({ systemActions, ...rule }) => rule),
},
});
})
);

Some files were not shown because too many files have changed in this diff Show more