Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Actions] Connector Adapters MVP #166101

Merged
merged 7 commits into from
Sep 12, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions x-pack/plugins/alerting/common/rule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,19 @@ export interface AlertsFilter extends SavedObjectAttributes {

export type RuleActionAlertsFilterProperty = AlertsFilterTimeframe | RuleActionParam;

/**
* The RuleActionTypes is being used in versioned
* routes and rule client's schemas. Renaming
* or removing a type will introduce a
* breaking change
*/
export const RuleActionTypes = {
DEFAULT: 'default' as const,
SYSTEM: 'system' as const,
} as const;
Comment on lines +117 to +120
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: Do we have a follow up issue / task for the feature branch to split DEFAULT into two? (something that associates with each alert and something that associates with summaries).


export type RuleActionTypes = typeof RuleActionTypes[keyof typeof RuleActionTypes];

export interface RuleAction {
uuid?: string;
group: string;
Expand All @@ -116,6 +129,7 @@ export interface RuleAction {
params: RuleActionParams;
frequency?: RuleActionFrequency;
alertsFilter?: AlertsFilter;
type?: typeof RuleActionTypes.DEFAULT;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The system action will be

export interface RuleSystemAction {
  uuid: string;
  id: string;
  actionTypeId: string;
  params: RuleActionParams;
  type: typeof RuleActionTypes.SYSTEM;
}

}

export interface AggregateOptions {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ 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';

jest.mock('../../../../rules_client/lib/siem_legacy_actions/migrate_legacy_actions', () => {
return {
Expand Down Expand Up @@ -102,6 +103,7 @@ const rulesClientParams: jest.Mocked<ConstructorOptions> = {
minimumScheduleInterval: { value: '1m', enforce: false },
isAuthenticationTypeAPIKey: isAuthenticationTypeApiKeyMock,
getAuthenticationAPIKey: getAuthenticationApiKeyMock,
connectorAdapterRegistry: new ConnectorAdapterRegistry(),
};
const paramsModifier = jest.fn();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { getBeforeSetup, setGlobalDate } from '../../../../rules_client/tests/li
import { RecoveredActionGroup } from '../../../../../common';
import { bulkMarkApiKeysForInvalidation } from '../../../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation';
import { getRuleExecutionStatusPending, getDefaultMonitoring } from '../../../../lib';
import { ConnectorAdapterRegistry } from '../../../../connector_adapters/connector_adapter_registry';

jest.mock('../../../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation', () => ({
bulkMarkApiKeysForInvalidation: jest.fn(),
Expand Down Expand Up @@ -82,6 +83,7 @@ const rulesClientParams: jest.Mocked<ConstructorOptions> = {
minimumScheduleInterval: { value: '1m', enforce: false },
isAuthenticationTypeAPIKey: jest.fn(),
getAuthenticationAPIKey: jest.fn(),
connectorAdapterRegistry: new ConnectorAdapterRegistry(),
};

beforeEach(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,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();
Expand Down Expand Up @@ -53,6 +54,7 @@ const rulesClientParams: jest.Mocked<ConstructorOptions> = {
minimumScheduleInterval: { value: '1m', enforce: false },
isAuthenticationTypeAPIKey: jest.fn(),
getAuthenticationAPIKey: jest.fn(),
connectorAdapterRegistry: new ConnectorAdapterRegistry(),
};

const getMockAggregationResult = (
Expand Down
Original file line number Diff line number Diff line change
@@ -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."`
);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/*
ymao1 marked this conversation as resolved.
Show resolved Hide resolved
* 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 } 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(connectorAdapter: ConnectorAdapter) {
if (this.has(connectorAdapter.connectorTypeId)) {
throw new Error(
`${connectorAdapter.connectorTypeId} is already registered to the ConnectorAdapterRegistry`
);
}

this.connectorAdapters.set(connectorAdapter.connectorTypeId, 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)!;
}
}
44 changes: 44 additions & 0 deletions x-pack/plugins/alerting/server/connector_adapters/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
* 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 {
RuleActionParams as GenericRuleActionParams,
RuleTypeParams,
SanitizedRule,
} from '../../common';
import { CombinedSummarizedAlerts } from '../types';

type ActionTypeParams = Record<string, unknown>;

type Rule = Pick<SanitizedRule<RuleTypeParams>, 'id' | 'name' | 'tags'>;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the MVP this should be enough. If a connector adapter needs more attributes from the rule we can extend it in the future.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: do we need to extend or can we pass-through SanitizedRule<RuleTypeParams>?


interface BuildActionParamsArgs<
RuleActionParams extends GenericRuleActionParams = GenericRuleActionParams
> {
alerts: CombinedSummarizedAlerts;
rule: Rule;
params: RuleActionParams;
spaceId: string;
ruleUrl?: string;
}

export interface ConnectorAdapter {
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: <RuleActionParams extends GenericRuleActionParams>(
args: BuildActionParamsArgs<RuleActionParams>
) => ActionTypeParams;
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ interface AlertOpts {
maintenanceWindowIds?: string[];
}

interface ActionOpts {
export interface ActionOpts {
id: string;
typeId: string;
alertId?: string;
Expand Down
1 change: 1 addition & 0 deletions x-pack/plugins/alerting/server/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const createSetupMock = () => {
getContextInitializationPromise: jest.fn(),
},
getDataStreamAdapter: jest.fn(),
registerConnectorAdapter: jest.fn(),
};
return mock;
};
Expand Down
29 changes: 28 additions & 1 deletion x-pack/plugins/alerting/server/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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',
Expand Down Expand Up @@ -222,6 +223,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()', () => {
Expand Down
9 changes: 9 additions & 0 deletions x-pack/plugins/alerting/server/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ import {
} from './alerts_service';
import { rulesSettingsFeature } from './rules_settings_feature';
import { maintenanceWindowFeature } from './maintenance_window_feature';
import { ConnectorAdapterRegistry } from './connector_adapters/connector_adapter_registry';
import { ConnectorAdapter } from './connector_adapters/types';
import { DataStreamAdapter, getDataStreamAdapter } from './alerts_service/lib/data_stream_adapter';
import { createGetAlertIndicesAliasFn, GetAlertIndicesAlias } from './lib';

Expand All @@ -117,6 +119,7 @@ export const LEGACY_EVENT_LOG_ACTIONS = {
};

export interface PluginSetupContract {
registerConnectorAdapter(adapter: ConnectorAdapter): void;
registerType<
Params extends RuleTypeParams = RuleTypeParams,
ExtractedParams extends RuleTypeParams = RuleTypeParams,
Expand Down Expand Up @@ -213,6 +216,7 @@ export class AlertingPlugin {
private alertsService: AlertsService | null;
private pluginStop$: Subject<void>;
private dataStreamAdapter?: DataStreamAdapter;
private readonly connectorAdapterRegistry = new ConnectorAdapterRegistry();

constructor(initializerContext: PluginInitializerContext) {
this.config = initializerContext.config.get();
Expand Down Expand Up @@ -368,6 +372,9 @@ export class AlertingPlugin {
});

return {
registerConnectorAdapter: (adapter: ConnectorAdapter) => {
this.connectorAdapterRegistry.register(adapter);
},
registerType: <
Params extends RuleTypeParams = never,
ExtractedParams extends RuleTypeParams = never,
Expand Down Expand Up @@ -494,6 +501,7 @@ export class AlertingPlugin {
eventLogger: this.eventLogger,
minimumScheduleInterval: this.config.rules.minimumScheduleInterval,
maxScheduledPerMinute: this.config.rules.maxScheduledPerMinute,
connectorAdapterRegistry: this.connectorAdapterRegistry,
});

rulesSettingsClientFactory.initialize({
Expand Down Expand Up @@ -556,6 +564,7 @@ export class AlertingPlugin {
usageCounter: this.usageCounter,
getRulesSettingsClientWithRequest,
getMaintenanceWindowClientWithRequest,
connectorAdapterRegistry: this.connectorAdapterRegistry,
});

this.eventLogService!.registerSavedObjectProvider('alert', (request) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { RawRule } from '../../types';
import { getBeforeSetup, mockedDateString } from '../tests/lib';
import { createNewAPIKeySet } from './create_new_api_key_set';
import { RulesClientContext } from '../types';
import { ConnectorAdapterRegistry } from '../../connector_adapters/connector_adapter_registry';

const taskManager = taskManagerMock.createStart();
const ruleTypeRegistry = ruleTypeRegistryMock.create();
Expand Down Expand Up @@ -52,6 +53,7 @@ const rulesClientParams: jest.Mocked<RulesClientContext> = {
fieldsToExcludeFromPublicApi: [],
isAuthenticationTypeAPIKey: jest.fn(),
getAuthenticationAPIKey: jest.fn(),
connectorAdapterRegistry: new ConnectorAdapterRegistry(),
};

const username = 'test';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
} from '../../../common';
import { RegistryRuleType } from '../../rule_type_registry';
import { fromKueryExpression, nodeTypes } from '@kbn/es-query';
import { ConnectorAdapterRegistry } from '../../connector_adapters/connector_adapter_registry';

const taskManager = taskManagerMock.createStart();
const ruleTypeRegistry = ruleTypeRegistryMock.create();
Expand Down Expand Up @@ -59,6 +60,7 @@ const rulesClientParams: jest.Mocked<ConstructorOptions> = {
kibanaVersion,
isAuthenticationTypeAPIKey: jest.fn(),
getAuthenticationAPIKey: jest.fn(),
connectorAdapterRegistry: new ConnectorAdapterRegistry(),
};

beforeEach(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
} from './test_helpers';
import { schema } from '@kbn/config-schema';
import { migrateLegacyActions } from '../lib';
import { ConnectorAdapterRegistry } from '../../connector_adapters/connector_adapter_registry';

jest.mock('../lib/siem_legacy_actions/migrate_legacy_actions', () => {
return {
Expand Down Expand Up @@ -78,6 +79,7 @@ const rulesClientParams: jest.Mocked<ConstructorOptions> = {
minimumScheduleInterval: { value: '1m', enforce: false },
isAuthenticationTypeAPIKey: jest.fn(),
getAuthenticationAPIKey: jest.fn(),
connectorAdapterRegistry: new ConnectorAdapterRegistry(),
};

const getBulkOperationStatusErrorResponse = (statusCode: number) => ({
Expand Down
Loading