From fd3ce863310e5e6a78423e023b834044209d306e Mon Sep 17 00:00:00 2001 From: Patrick Mueller Date: Wed, 25 Sep 2024 19:37:49 -0400 Subject: [PATCH] [ResponseOps] add pre-create, pre-update, and post-delete hooks for connectors Extracted from https://github.com/elastic/kibana/pull/189027, commit c97afebbe1462eb3eb2b0fb89d0ce9126ff118db Allows connector types to add functions to be called when connectors are created, updated, and deleted. --- x-pack/plugins/actions/README.md | 71 ++++++- .../actions_client/actions_client.test.ts | 197 ++++++++++++++++-- .../server/actions_client/actions_client.ts | 118 ++++++++++- .../sub_action_framework/register.test.ts | 13 +- .../server/sub_action_framework/register.ts | 3 + .../server/sub_action_framework/types.ts | 34 +++ x-pack/plugins/actions/server/types.ts | 46 ++++ .../plugins/alerts/server/action_types.ts | 61 ++++++ .../group2/tests/actions/create.ts | 60 ++++++ .../group2/tests/actions/delete.ts | 47 +++++ 10 files changed, 619 insertions(+), 31 deletions(-) diff --git a/x-pack/plugins/actions/README.md b/x-pack/plugins/actions/README.md index 7cab1ffe0c0b3..7a6ff229d9bd4 100644 --- a/x-pack/plugins/actions/README.md +++ b/x-pack/plugins/actions/README.md @@ -89,13 +89,16 @@ The following table describes the properties of the `options` object. | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------- | | id | Unique identifier for the action type. For convention, ids starting with `.` are reserved for built in action types. We recommend using a convention like `.mySpecialAction` for your action types. | string | | name | A user-friendly name for the action type. These will be displayed in dropdowns when chosing action types. | string | -| maxAttempts | The maximum number of times this action will attempt to run when scheduled. | number | +| maxAttempts | The maximum number of times this action will attempt to run when scheduled. | number | | minimumLicenseRequired | The license required to use the action type. | string | | supportedFeatureIds | List of IDs of the features that this action type is available in. Allowed values are `alerting`, `siem`, `uptime`, `cases`. See `x-pack/plugins/actions/common/connector_feature_config.ts` for the most up to date list. | string[] | | validate.params | When developing an action type, it needs to accept parameters to know what to do with the action. (Example `to`, `from`, `subject`, `body` of an email). See the current built-in email action type for an example of the state-of-the-art validation.

Technically, the value of this property should have a property named `validate()` which is a function that takes a params object to validate and returns a sanitized version of that object to pass to the execution function. Validation errors should be thrown from the `validate()` function and will be available as an error message | schema / validation function | | validate.config | Similar to params, a config may be required when creating an action (for example `host` and `port` for an email server). | schema / validation function | | validate.secrets | Similar to params, a secrets object may be required when creating an action (for example `user` and `password` for an email server). | schema / validation function | -| executor | This is where the code of an action type lives. This is a function gets called for generating an action from either alerting or manually by using the exposed function (see firing actions). For full details, see executor section below. | Function | +| executor | This is where the code of an action type lives. This is a function gets called for generating an action from either alerting or manually by using the exposed function (see firing actions). For full details, see executor section below. | Function | +| preSaveHook | This optional function is called before the connector saved object is saved. For full details, see hooks section below. | Function | +| postSaveHook | This optional function is called after the connector saved object is saved. For full details, see hooks section below. | Function | +| postDeleteHook | This optional function is called after the connector saved object is deleted. For full details, see hooks section below. | Function | | renderParameterTemplates | Optionally define a function to provide custom rendering for this action type. | Function | **Important** - The config object is persisted in ElasticSearch and updated via the ElasticSearch update document API. This API allows "partial updates" - and this can cause issues with the encryption used on specified properties. So, a `validate()` function should return values for all configuration properties, so that partial updates do not occur. Setting property values to `null` rather than `undefined`, or not including a property in the config object, is all you need to do to ensure partial updates won't occur. @@ -116,6 +119,70 @@ This is the primary function for an action type. Whenever the action needs to ru | services.savedObjectsClient | This is an instance of the saved objects client. This provides the ability to do CRUD on any saved objects within the same space the alert lives in.

The scope of the saved objects client is tied to the user in context calling the execute API or the API key provided to the execute plugin function (only when security isenabled). | | services.log(tags, [data], [timestamp]) | Use this to create server logs. (This is the same function as server.log) | +### Hooks + +Hooks allow a connector implementation to be called during connector creation, update, and delete. When not using hooks, the connector implementation is not involved in creation, update and delete, except for the schema validation that happens for creation and update. Hooks can be used to force a create or update to fail, or run arbitrary code +before and after update and create, and after delete. We don't have a need for a hook before delete at the moment, so that hook is currently not available. + +Hooks are passed the following parameters: + +```ts +interface PreSaveConnectorHookParams { + connectorId: string; + config: Config; + secrets: Secrets; + logger: Logger; + request: KibanaRequest; + services: HookServices; + isUpdate: boolean; +} + +interface PostSaveConnectorHookParams { + connectorId: string; + config: Config; + secrets: Secrets; + logger: Logger; + request: KibanaRequest; + services: HookServices; + isUpdate: boolean; + wasSuccessful: boolean; +} + +interface PostDeleteConnectorHookParams { + connectorId: string; + config: Config; + secrets: Secrets; + logger: Logger; + request: KibanaRequest; + services: HookServices; +} +``` + +| parameter | description +| --------- | ----------- +| `connectorId` | The id of the connector. +| `config` | The connector's `config` object. +| `secrets` | The connector's `secrets` object. +| `logger` | A standard Kibana logger. +| `request` | The request causing this operation +| `services` | Common service objects, see below. +| `isUpdate` | For the `PreSave` and `PostSave` hooks, `isUpdate` is false for create operations, and true for update operations. +| `wasSuccessful` | For the `PostSave` hook, this indicates if the connector was persisted as a Saved Object successfully. + +The `services` object contains the following properties: + +| property | description +| --------- | ----------- +| `scopedClusterClient` | A standard `scopeClusterClient` object. + +The hooks are called just before, and just after, the Saved Object operation for the client methods is invoked. + +The `PostDelete` hook does not have a `wasSuccessful` property, as the hook is not called if the delete operation fails. The saved object will still exist. Only a successful call to delete the connector will cause the hook to run. + +The `PreSave` hook can be used to cancel a create or update, by throwing an exception. The `PostSave` and `PostDelete` invocations will have thrown exceptions caught and logged to the Kibana log, and will not cancel the operation. + +When throwing an error in the `PreSave` hook, the Error's message will be used as the error failing the operation, so should include a human-readable description of what it was doing, along with any message from an underlying API that failed, if available. + ### Example The built-in email action type provides a good example of creating an action type with non-trivial configuration and params: diff --git a/x-pack/plugins/actions/server/actions_client/actions_client.test.ts b/x-pack/plugins/actions/server/actions_client/actions_client.test.ts index 46e73f7bb3591..012bb8dca4fa7 100644 --- a/x-pack/plugins/actions/server/actions_client/actions_client.test.ts +++ b/x-pack/plugins/actions/server/actions_client/actions_client.test.ts @@ -113,6 +113,9 @@ const mockTaskManager = taskManagerMock.createSetup(); const configurationUtilities = actionsConfigMock.create(); const eventLogClient = eventLogClientMock.create(); const getEventLogClient = jest.fn(); +const preSaveHook = jest.fn(); +const postSaveHook = jest.fn(); +const postDeleteHook = jest.fn(); let actionsClient: ActionsClient; let mockedLicenseState: jest.Mocked; @@ -377,7 +380,8 @@ describe('create()', () => { name: 'my name', actionTypeId: 'my-action-type', isMissingSecrets: false, - config: {}, + config: { foo: 42 }, + secrets: { bar: 2001 }, }, references: [], }; @@ -387,19 +391,25 @@ describe('create()', () => { minimumLicenseRequired: 'basic', supportedFeatureIds: ['alerting'], validate: { - config: { schema: schema.object({}) }, - secrets: { schema: schema.object({}) }, + config: { schema: schema.object({ foo: schema.number() }) }, + secrets: { schema: schema.object({ bar: schema.number() }) }, params: { schema: schema.object({}) }, }, executor, + preSaveHook: async (params) => { + preSaveHook(params); + }, + postSaveHook: async (params) => { + postSaveHook(params); + }, }); unsecuredSavedObjectsClient.create.mockResolvedValueOnce(savedObjectCreateResult); const result = await actionsClient.create({ action: { name: 'my name', actionTypeId: 'my-action-type', - config: {}, - secrets: {}, + config: { foo: 42 }, + secrets: { bar: 2001 }, }, }); expect(result).toEqual({ @@ -410,7 +420,7 @@ describe('create()', () => { name: 'my name', actionTypeId: 'my-action-type', isMissingSecrets: false, - config: {}, + config: { foo: 42 }, }); expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(1); expect(unsecuredSavedObjectsClient.create.mock.calls[0]).toMatchInlineSnapshot(` @@ -418,16 +428,51 @@ describe('create()', () => { "action", Object { "actionTypeId": "my-action-type", - "config": Object {}, + "config": Object { + "foo": 42, + }, "isMissingSecrets": false, "name": "my name", - "secrets": Object {}, + "secrets": Object { + "bar": 2001, + }, }, Object { "id": "mock-saved-object-id", }, ] `); + expect(preSaveHook).toHaveBeenCalledTimes(1); + expect(preSaveHook.mock.calls[0]).toEqual([ + { + connectorId: 'mock-saved-object-id', + config: { foo: 42 }, + secrets: { bar: 2001 }, + logger, + request, + services: { + // this will be checked with a function test + scopedClusterClient: expect.any(Object), + }, + isUpdate: false, + }, + ]); + expect(postSaveHook).toHaveBeenCalledTimes(1); + expect(postSaveHook.mock.calls[0]).toEqual([ + { + connectorId: 'mock-saved-object-id', + config: { foo: 42 }, + secrets: { bar: 2001 }, + logger, + request, + services: { + // this will be checked with a function test + scopedClusterClient: expect.any(Object), + }, + isUpdate: false, + wasSuccessful: true, + }, + ]); }); test('validates config', async () => { @@ -1973,6 +2018,33 @@ describe('getOAuthAccessToken()', () => { }); describe('delete()', () => { + beforeEach(() => { + actionTypeRegistry.register({ + id: 'my-action-delete', + name: 'My action type', + minimumLicenseRequired: 'basic', + supportedFeatureIds: ['alerting'], + validate: { + config: { schema: schema.object({ foo: schema.number() }) }, + secrets: { schema: schema.object({ bar: schema.number() }) }, + params: { schema: schema.object({}) }, + }, + executor, + postDeleteHook: async (options) => postDeleteHook(options), + }); + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: 'action', + attributes: { + actionTypeId: 'my-action-delete', + isMissingSecrets: false, + config: { foo: 42 }, + secrets: { bar: 2001 }, + }, + references: [], + }); + }); + describe('authorization', () => { test('ensures user is authorised to delete actions', async () => { await actionsClient.delete({ id: '1' }); @@ -1998,6 +2070,7 @@ describe('delete()', () => { test(`failing to delete tokens logs error instead of throw`, async () => { connectorTokenClient.deleteConnectorTokens.mockRejectedValueOnce(new Error('Fail')); + await expect(actionsClient.delete({ id: '1' })).resolves.toBeUndefined(); expect(logger.error).toHaveBeenCalledWith( `Failed to delete auth tokens for connector "1" after delete: Fail` @@ -2041,6 +2114,7 @@ describe('delete()', () => { test('calls unsecuredSavedObjectsClient with id', async () => { const expectedResult = Symbol(); unsecuredSavedObjectsClient.delete.mockResolvedValueOnce(expectedResult); + const result = await actionsClient.delete({ id: '1' }); expect(result).toEqual(expectedResult); expect(unsecuredSavedObjectsClient.delete).toHaveBeenCalledTimes(1); @@ -2052,6 +2126,35 @@ describe('delete()', () => { `); }); + test('calls postDeleteHook', async () => { + const expectedResult = Symbol(); + unsecuredSavedObjectsClient.delete.mockResolvedValueOnce(expectedResult); + + const result = await actionsClient.delete({ id: '1' }); + expect(result).toEqual(expectedResult); + expect(unsecuredSavedObjectsClient.delete).toHaveBeenCalledTimes(1); + expect(unsecuredSavedObjectsClient.delete.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "action", + "1", + ] + `); + expect(postDeleteHook).toHaveBeenCalledTimes(1); + expect(postDeleteHook.mock.calls[0]).toEqual([ + { + connectorId: '1', + config: { foo: 42 }, + secrets: { bar: 2001 }, + logger, + request, + services: { + // this will be checked with a function test + scopedClusterClient: expect.any(Object), + }, + }, + ]); + }); + it('throws when trying to delete a preconfigured connector', async () => { actionsClient = new ActionsClient({ logger, @@ -2141,6 +2244,12 @@ describe('update()', () => { params: { schema: schema.object({}) }, }, executor, + preSaveHook: async (params) => { + preSaveHook(params); + }, + postSaveHook: async (params) => { + postSaveHook(params); + }, }); unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ id: '1', @@ -2245,11 +2354,17 @@ describe('update()', () => { minimumLicenseRequired: 'basic', supportedFeatureIds: ['alerting'], validate: { - config: { schema: schema.object({}) }, - secrets: { schema: schema.object({}) }, + config: { schema: schema.object({ foo: schema.number() }) }, + secrets: { schema: schema.object({ bar: schema.number() }) }, params: { schema: schema.object({}) }, }, executor, + preSaveHook: async (params) => { + preSaveHook(params); + }, + postSaveHook: async (params) => { + postSaveHook(params); + }, }); unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ id: '1', @@ -2267,8 +2382,8 @@ describe('update()', () => { actionTypeId: 'my-action-type', isMissingSecrets: false, name: 'my name', - config: {}, - secrets: {}, + config: { foo: 42 }, + secrets: { bar: 2001 }, }, references: [], }); @@ -2276,8 +2391,8 @@ describe('update()', () => { id: 'my-action', action: { name: 'my name', - config: {}, - secrets: {}, + config: { foo: 42 }, + secrets: { bar: 2001 }, }, }); expect(result).toEqual({ @@ -2288,7 +2403,7 @@ describe('update()', () => { actionTypeId: 'my-action-type', isMissingSecrets: false, name: 'my name', - config: {}, + config: { foo: 42 }, }); expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(1); expect(unsecuredSavedObjectsClient.create.mock.calls[0]).toMatchInlineSnapshot(` @@ -2296,10 +2411,14 @@ describe('update()', () => { "action", Object { "actionTypeId": "my-action-type", - "config": Object {}, + "config": Object { + "foo": 42, + }, "isMissingSecrets": false, "name": "my name", - "secrets": Object {}, + "secrets": Object { + "bar": 2001, + }, }, Object { "id": "my-action", @@ -2315,6 +2434,39 @@ describe('update()', () => { "my-action", ] `); + + expect(preSaveHook).toHaveBeenCalledTimes(1); + expect(preSaveHook.mock.calls[0]).toEqual([ + { + connectorId: 'my-action', + config: { foo: 42 }, + secrets: { bar: 2001 }, + logger, + request, + services: { + // this will be checked with a function test + scopedClusterClient: expect.any(Object), + }, + isUpdate: true, + }, + ]); + + expect(postSaveHook).toHaveBeenCalledTimes(1); + expect(postSaveHook.mock.calls[0]).toEqual([ + { + connectorId: 'my-action', + config: { foo: 42 }, + secrets: { bar: 2001 }, + logger, + request, + services: { + // this will be checked with a function test + scopedClusterClient: expect.any(Object), + }, + isUpdate: true, + wasSuccessful: true, + }, + ]); }); test('updates an action with isMissingSecrets "true" (set true as the import result), to isMissingSecrets', async () => { @@ -3549,3 +3701,14 @@ describe('getGlobalExecutionKpiWithAuth()', () => { expect(eventLogClient.aggregateEventsWithAuthFilter).toHaveBeenCalled(); }); }); + +describe('hook failure cases', () => { + test('preSave hook throws error in create', async () => {}); + test('preSave hook throws error in update', async () => {}); + test('postSave hook throws error in create', async () => {}); + test('postSave hook throws error in update', async () => {}); + test('postDelete hook throws error in delete', async () => {}); + test('postSave hook called on SO error in create', async () => {}); + test('postSave hook called on SO error in update', async () => {}); + test('postDelete hook not called on SO error in delete', async () => {}); +}); diff --git a/x-pack/plugins/actions/server/actions_client/actions_client.ts b/x-pack/plugins/actions/server/actions_client/actions_client.ts index 7e4d72faedaed..1a25c9afd2d7e 100644 --- a/x-pack/plugins/actions/server/actions_client/actions_client.ts +++ b/x-pack/plugins/actions/server/actions_client/actions_client.ts @@ -50,6 +50,7 @@ import { InMemoryConnector, ActionTypeExecutorResult, ConnectorTokenClientContract, + HookServices, } from '../types'; import { PreconfiguredActionDisabledModificationError } from '../lib/errors/preconfigured_action_disabled_modification'; import { ExecuteOptions } from '../lib/action_executor'; @@ -246,6 +247,33 @@ export class ActionsClient { } this.context.actionTypeRegistry.ensureActionTypeEnabled(actionTypeId); + const hookServices: HookServices = { + scopedClusterClient: this.context.scopedClusterClient.asCurrentUser, + }; + + if (actionType.preSaveHook) { + try { + await actionType.preSaveHook({ + connectorId: id, + config, + secrets, + logger: this.context.logger, + request: this.context.request, + services: hookServices, + isUpdate: false, + }); + } catch (error) { + this.context.auditLogger?.log( + connectorAuditEvent({ + action: ConnectorAuditAction.CREATE, + savedObject: { type: 'action', id }, + error, + }) + ); + throw error; + } + } + this.context.auditLogger?.log( connectorAuditEvent({ action: ConnectorAuditAction.CREATE, @@ -254,18 +282,48 @@ export class ActionsClient { }) ); - const result = await this.context.unsecuredSavedObjectsClient.create( - 'action', - { - actionTypeId, - name, - isMissingSecrets: false, - config: validatedActionTypeConfig as SavedObjectAttributes, - secrets: validatedActionTypeSecrets as SavedObjectAttributes, - }, - { id } + const result = await tryCatch( + async () => + await this.context.unsecuredSavedObjectsClient.create( + 'action', + { + actionTypeId, + name, + isMissingSecrets: false, + config: validatedActionTypeConfig as SavedObjectAttributes, + secrets: validatedActionTypeSecrets as SavedObjectAttributes, + }, + { id } + ) ); + const wasSuccessful = !(result instanceof Error); + const label = `connectorId: "${id}"; type: ${actionTypeId}`; + const tags = ['post-save-hook', id]; + + if (actionType.postSaveHook) { + try { + await actionType.postSaveHook({ + connectorId: id, + config, + secrets, + logger: this.context.logger, + request: this.context.request, + services: hookServices, + isUpdate: false, + wasSuccessful, + }); + } catch (err) { + this.context.logger.error(`postSaveHook crearte error for ${label}: ${err.message}`, { + tags, + }); + } + } + + if (!wasSuccessful) { + throw result; + } + return { id: result.id, actionTypeId: result.attributes.actionTypeId, @@ -558,7 +616,37 @@ export class ActionsClient { ); } - return await this.context.unsecuredSavedObjectsClient.delete('action', id); + const { attributes } = await this.context.unsecuredSavedObjectsClient.get( + 'action', + id + ); + const { actionTypeId, config, secrets } = attributes; + const actionType = this.context.actionTypeRegistry.get(actionTypeId); + const result = await this.context.unsecuredSavedObjectsClient.delete('action', id); + + const hookServices: HookServices = { + scopedClusterClient: this.context.scopedClusterClient.asCurrentUser, + }; + + if (actionType.postDeleteHook) { + try { + await actionType.postDeleteHook({ + connectorId: id, + config, + secrets, + logger: this.context.logger, + request: this.context.request, + services: hookServices, + }); + } catch (error) { + const tags = ['post-delete-hook', id]; + this.context.logger.error( + `The post delete hook failed for for connector "${id}": ${error.message}`, + { tags } + ); + } + } + return result; } private getSystemActionKibanaPrivileges(connectorId: string, params?: ExecuteOptions['params']) { @@ -832,3 +920,11 @@ export class ActionsClient { } } } + +async function tryCatch(fn: () => Promise): Promise { + try { + return await fn(); + } catch (err) { + return err; + } +} diff --git a/x-pack/plugins/actions/server/sub_action_framework/register.test.ts b/x-pack/plugins/actions/server/sub_action_framework/register.test.ts index a0e56c1a39b80..8ae7f3cf3350f 100644 --- a/x-pack/plugins/actions/server/sub_action_framework/register.test.ts +++ b/x-pack/plugins/actions/server/sub_action_framework/register.test.ts @@ -21,6 +21,9 @@ import { ServiceParams } from './types'; describe('Registration', () => { const renderedVariables = { body: '' }; const mockRenderParameterTemplates = jest.fn().mockReturnValue(renderedVariables); + const mockPreSaveHook = jest.fn(); + const mockPostSaveHook = jest.fn(); + const mockPostDeleteHook = jest.fn(); const connector = { id: '.test', @@ -47,7 +50,12 @@ describe('Registration', () => { it('registers the connector correctly', async () => { register({ actionTypeRegistry, - connector, + connector: { + ...connector, + preSaveHook: mockPreSaveHook, + postSaveHook: mockPostSaveHook, + postDeleteHook: mockPostDeleteHook, + }, configurationUtilities: mockedActionsConfig, logger, }); @@ -62,6 +70,9 @@ describe('Registration', () => { executor: expect.any(Function), getService: expect.any(Function), renderParameterTemplates: expect.any(Function), + preSaveHook: expect.any(Function), + postSaveHook: expect.any(Function), + postDeleteHook: expect.any(Function), }); }); diff --git a/x-pack/plugins/actions/server/sub_action_framework/register.ts b/x-pack/plugins/actions/server/sub_action_framework/register.ts index dd05cc4e99967..04e7f0d9ea417 100644 --- a/x-pack/plugins/actions/server/sub_action_framework/register.ts +++ b/x-pack/plugins/actions/server/sub_action_framework/register.ts @@ -43,5 +43,8 @@ export const register = { /** @@ -76,6 +77,36 @@ export type Validators = Array< ConfigValidator | SecretsValidator >; +export interface PreSaveConnectorHookParams { + connectorId: string; + config: Config; + secrets: Secrets; + logger: Logger; + request: KibanaRequest; + services: HookServices; + isUpdate: boolean; +} + +export interface PostSaveConnectorHookParams { + connectorId: string; + config: Config; + secrets: Secrets; + logger: Logger; + request: KibanaRequest; + services: HookServices; + isUpdate: boolean; + wasSuccessful: boolean; +} + +export interface PostDeleteConnectorHookParams { + connectorId: string; + config: Config; + secrets: Secrets; + logger: Logger; + services: HookServices; + request: KibanaRequest; +} + export interface SubActionConnectorType { id: string; name: string; @@ -92,6 +123,9 @@ export interface SubActionConnectorType { getKibanaPrivileges?: (args?: { params?: { subAction: string; subActionParams: Record }; }) => string[]; + preSaveHook?: (params: PreSaveConnectorHookParams) => Promise; + postSaveHook?: (params: PostSaveConnectorHookParams) => Promise; + postDeleteHook?: (params: PostDeleteConnectorHookParams) => Promise; } export interface ExecutorParams extends ActionTypeParams { diff --git a/x-pack/plugins/actions/server/types.ts b/x-pack/plugins/actions/server/types.ts index 487e7630d40f9..a0a3e8604a66c 100644 --- a/x-pack/plugins/actions/server/types.ts +++ b/x-pack/plugins/actions/server/types.ts @@ -57,6 +57,10 @@ export interface UnsecuredServices { connectorTokenClient: ConnectorTokenClient; } +export interface HookServices { + scopedClusterClient: ElasticsearchClient; +} + export interface ActionsApiRequestHandlerContext { getActionsClient: () => ActionsClient; listTypes: ActionTypeRegistry['list']; @@ -138,6 +142,45 @@ export type RenderParameterTemplates = ( actionId?: string ) => Params; +export interface PreSaveConnectorHookParams< + Config extends ActionTypeConfig = ActionTypeConfig, + Secrets extends ActionTypeSecrets = ActionTypeSecrets +> { + connectorId: string; + config: Config; + secrets: Secrets; + logger: Logger; + request: KibanaRequest; + services: HookServices; + isUpdate: boolean; +} + +export interface PostSaveConnectorHookParams< + Config extends ActionTypeConfig = ActionTypeConfig, + Secrets extends ActionTypeSecrets = ActionTypeSecrets +> { + connectorId: string; + config: Config; + secrets: Secrets; + logger: Logger; + request: KibanaRequest; + services: HookServices; + isUpdate: boolean; + wasSuccessful: boolean; +} + +export interface PostDeleteConnectorHookParams< + Config extends ActionTypeConfig = ActionTypeConfig, + Secrets extends ActionTypeSecrets = ActionTypeSecrets +> { + connectorId: string; + config: Config; + secrets: Secrets; + logger: Logger; + request: KibanaRequest; + services: HookServices; +} + export interface ActionType< Config extends ActionTypeConfig = ActionTypeConfig, Secrets extends ActionTypeSecrets = ActionTypeSecrets, @@ -171,6 +214,9 @@ export interface ActionType< renderParameterTemplates?: RenderParameterTemplates; executor: ExecutorType; getService?: (params: ServiceParams) => SubActionConnector; + preSaveHook?: (params: PreSaveConnectorHookParams) => Promise; + postSaveHook?: (params: PostSaveConnectorHookParams) => Promise; + postDeleteHook?: (params: PostDeleteConnectorHookParams) => Promise; } export interface RawAction extends Record { diff --git a/x-pack/test/alerting_api_integration/common/plugins/alerts/server/action_types.ts b/x-pack/test/alerting_api_integration/common/plugins/alerts/server/action_types.ts index f6903da3c62bc..71f06413b436f 100644 --- a/x-pack/test/alerting_api_integration/common/plugins/alerts/server/action_types.ts +++ b/x-pack/test/alerting_api_integration/common/plugins/alerts/server/action_types.ts @@ -76,6 +76,7 @@ export function defineActionTypes( actions.registerType(getNoAttemptsRateLimitedActionType()); actions.registerType(getAuthorizationActionType(core)); actions.registerType(getExcludedActionType()); + actions.registerType(getHookedActionType()); /** * System actions @@ -139,6 +140,66 @@ function getIndexRecordActionType() { return result; } +function getHookedActionType() { + const paramsSchema = schema.object({}); + type ParamsType = TypeOf; + const configSchema = schema.object({ + index: schema.string(), + reference: schema.string(), + }); + type ConfigType = TypeOf; + const secretsSchema = schema.object({ + encrypted: schema.string(), + }); + type SecretsType = TypeOf; + const result: ActionType = { + id: 'test.connector-with-hooks', + name: 'Test: Connector with hooks', + minimumLicenseRequired: 'basic', + supportedFeatureIds: ['alerting'], + validate: { + params: { schema: paramsSchema }, + config: { schema: configSchema }, + secrets: { schema: secretsSchema }, + }, + async executor({ config, secrets, params, services, actionId }) { + return { status: 'ok', actionId }; + }, + async preSaveHook({ config, secrets, services, isUpdate, logger }) { + // just validate it doesn't blow up :-) + logger.info('running a pre-save hook for a connector'); + + await services.scopedClusterClient.index({ + index: config.index, + refresh: 'wait_for', + body: { + config, + secrets, + isUpdate, + reference: config.reference, + source: 'action:test.connector-with-hooks-pre-save', + }, + }); + }, + async postDeleteHook({ config, secrets, services, logger }) { + // just validate it doesn't blow up :-) + logger.info('running a pre-save hook for a connector'); + + await services.scopedClusterClient.index({ + index: config.index, + refresh: 'wait_for', + body: { + config, + secrets, + reference: config.reference, + source: 'action:test.connector-with-hooks-post-delete', + }, + }); + }, + }; + return result; +} + function getDelayedActionType() { const paramsSchema = schema.object({ delayInMs: schema.number({ defaultValue: 1000 }), diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/create.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/create.ts index 017fd3e45999b..dc3bbc2ddd645 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/create.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/create.ts @@ -83,6 +83,66 @@ export default function createActionTests({ getService }: FtrProviderContext) { } }); + it('should call the pre-save hook appropriately', async () => { + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(space.id)}/api/actions/connector`) + .auth(user.username, user.password) + .set('kbn-xsrf', 'foo') + .send({ + name: 'My action', + connector_type_id: 'test.connector-with-hooks', + config: { + index: '???', + reference: uuidv4(), + }, + secrets: { + encrypted: 'This value should be encrypted', + }, + }); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'global_read at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'space_1_all at space2': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + statusCode: 403, + error: 'Forbidden', + message: 'Unauthorized to create a "test.index-record" action', + }); + break; + case 'superuser at space1': + case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(200); + objectRemover.add(space.id, response.body.id, 'action', 'actions'); + expect(response.body).to.eql({ + id: response.body.id, + is_preconfigured: false, + is_system_action: false, + is_deprecated: false, + is_missing_secrets: false, + name: 'My action', + connector_type_id: 'test.index-record', + config: { + unencrypted: `This value shouldn't get encrypted`, + }, + }); + expect(typeof response.body.id).to.be('string'); + // Ensure AAD isn't broken + await checkAAD({ + supertest, + spaceId: space.id, + type: 'action', + id: response.body.id, + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + it(`should handle create action request appropriately when action type isn't registered`, async () => { const response = await supertestWithoutAuth .post(`${getUrlPrefix(space.id)}/api/actions/connector`) diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/delete.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/delete.ts index b5b11036a3dfd..6d23c7274c46d 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/delete.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/delete.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { v4 as uuidv4 } from 'uuid'; import expect from '@kbn/expect'; import { UserAtSpaceScenarios } from '../../../scenarios'; @@ -69,6 +70,52 @@ export default function deleteActionTests({ getService }: FtrProviderContext) { } }); + it('should call the post-save hook appropriately', async () => { + const { body: createdAction } = await supertest + .post(`${getUrlPrefix(space.id)}/api/actions/connector`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'My action', + connector_type_id: 'test.connector-with-hooks', + config: { + index: '???', + reference: uuidv4(), + }, + secrets: { + encrypted: 'This value should be encrypted', + }, + }) + .expect(200); + + const response = await supertestWithoutAuth + .delete(`${getUrlPrefix(space.id)}/api/actions/connector/${createdAction.id}`) + .auth(user.username, user.password) + .set('kbn-xsrf', 'foo'); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'global_read at space1': + case 'space_1_all at space2': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + statusCode: 403, + error: 'Forbidden', + message: 'Unauthorized to delete actions', + }); + objectRemover.add(space.id, createdAction.id, 'action', 'actions'); + break; + case 'superuser at space1': + case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(204); + expect(response.body).to.eql(''); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + it(`shouldn't delete action from another space`, async () => { const { body: createdAction } = await supertest .post(`${getUrlPrefix(space.id)}/api/actions/connector`)