From 57096d1f4fbcb4fc0135505b6c6100566ff08cc9 Mon Sep 17 00:00:00 2001 From: Patrick Mueller Date: Wed, 9 Oct 2024 17:52:09 -0400 Subject: [PATCH] [ResponseOps] add pre-create, pre-update, and post-delete hooks for connectors (#194081) Allows connector types to add functions to be called when connectors are created, updated, and deleted. Extracted from https://github.com/elastic/kibana/pull/189027, commit c97afebbe1462eb3eb2b0fb89d0ce9126ff118db Co-authored-by: Yuliia Naumenko --- x-pack/plugins/actions/README.md | 72 +++- .../actions_client/actions_client.test.ts | 49 +++ .../server/actions_client/actions_client.ts | 110 ++++- .../actions_client_hooks.test.ts | 385 ++++++++++++++++++ .../connector/methods/update/update.ts | 98 ++++- x-pack/plugins/actions/server/lib/index.ts | 1 + .../plugins/actions/server/lib/try_catch.ts | 17 + .../sub_action_framework/register.test.ts | 13 +- .../server/sub_action_framework/register.ts | 3 + .../server/sub_action_framework/types.ts | 33 ++ x-pack/plugins/actions/server/types.ts | 46 +++ .../alerting_api_integration/common/config.ts | 1 + .../plugins/alerts/server/action_types.ts | 91 +++++ .../group2/tests/actions/create.ts | 81 +++- .../group2/tests/actions/delete.ts | 85 +++- .../group2/tests/actions/update.ts | 103 ++++- 16 files changed, 1151 insertions(+), 37 deletions(-) create mode 100644 x-pack/plugins/actions/server/actions_client/actions_client_hooks.test.ts create mode 100644 x-pack/plugins/actions/server/lib/try_catch.ts diff --git a/x-pack/plugins/actions/README.md b/x-pack/plugins/actions/README.md index 7cab1ffe0c0b3..4e7f20e47cb7d 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,71 @@ 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 not provided, yet + 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 `PostSave` hook is useful if the `PreSave` hook is creating / modifying other resources. The `PreSave` hook is called just before the connector SO is actually created/updated, and of course that create/update could fail for some reason. In those cases, the `PostSave` hook is passed `wasSuccessful: false` and can "undo" any work it did in the `PreSave` hook. + +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. When an error is thrown from a `PreSave` hook, the `PostSave` hook will **NOT** be run. + ### 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..7f15dd6287d6b 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; @@ -392,6 +395,8 @@ describe('create()', () => { params: { schema: schema.object({}) }, }, executor, + preSaveHook, + postSaveHook, }); unsecuredSavedObjectsClient.create.mockResolvedValueOnce(savedObjectCreateResult); const result = await actionsClient.create({ @@ -428,6 +433,8 @@ describe('create()', () => { }, ] `); + expect(preSaveHook).toHaveBeenCalledTimes(1); + expect(postSaveHook).toHaveBeenCalledTimes(1); }); test('validates config', async () => { @@ -1973,6 +1980,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({}) }, + secrets: { schema: schema.object({}) }, + 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: {}, + secrets: {}, + }, + references: [], + }); + }); + describe('authorization', () => { test('ensures user is authorised to delete actions', async () => { await actionsClient.delete({ id: '1' }); @@ -2052,6 +2086,16 @@ 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(postDeleteHook).toHaveBeenCalledTimes(1); + }); + it('throws when trying to delete a preconfigured connector', async () => { actionsClient = new ActionsClient({ logger, @@ -2250,6 +2294,8 @@ describe('update()', () => { params: { schema: schema.object({}) }, }, executor, + preSaveHook, + postSaveHook, }); unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ id: '1', @@ -2315,6 +2361,9 @@ describe('update()', () => { "my-action", ] `); + + expect(preSaveHook).toHaveBeenCalledTimes(1); + expect(postSaveHook).toHaveBeenCalledTimes(1); }); test('updates an action with isMissingSecrets "true" (set true as the import result), to isMissingSecrets', 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..f485d82b2f120 100644 --- a/x-pack/plugins/actions/server/actions_client/actions_client.ts +++ b/x-pack/plugins/actions/server/actions_client/actions_client.ts @@ -43,6 +43,7 @@ import { validateConnector, ActionExecutionSource, parseDate, + tryCatch, } from '../lib'; import { ActionResult, @@ -50,6 +51,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 +248,33 @@ export class ActionsClient { } this.context.actionTypeRegistry.ensureActionTypeEnabled(actionTypeId); + const hookServices: HookServices = { + scopedClusterClient: this.context.scopedClusterClient, + }; + + 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 +283,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 create error for ${label}: ${err.message}`, { + tags, + }); + } + } + + if (!wasSuccessful) { + throw result; + } + return { id: result.id, actionTypeId: result.attributes.actionTypeId, @@ -558,7 +617,36 @@ export class ActionsClient { ); } - return await this.context.unsecuredSavedObjectsClient.delete('action', id); + const rawAction = await this.context.unsecuredSavedObjectsClient.get('action', id); + const { + attributes: { actionTypeId, config }, + } = rawAction; + + const actionType = this.context.actionTypeRegistry.get(actionTypeId); + const result = await this.context.unsecuredSavedObjectsClient.delete('action', id); + + const hookServices: HookServices = { + scopedClusterClient: this.context.scopedClusterClient, + }; + + if (actionType.postDeleteHook) { + try { + await actionType.postDeleteHook({ + connectorId: id, + config, + 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']) { diff --git a/x-pack/plugins/actions/server/actions_client/actions_client_hooks.test.ts b/x-pack/plugins/actions/server/actions_client/actions_client_hooks.test.ts new file mode 100644 index 0000000000000..7a1a0fb5e3d91 --- /dev/null +++ b/x-pack/plugins/actions/server/actions_client/actions_client_hooks.test.ts @@ -0,0 +1,385 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { MockedLogger, loggerMock } from '@kbn/logging-mocks'; +import { ActionTypeRegistry, ActionTypeRegistryOpts } from '../action_type_registry'; +import { ActionsClient } from './actions_client'; +import { ExecutorType } from '../types'; +import { ActionExecutor, TaskRunnerFactory, ILicenseState } from '../lib'; +import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks'; +import { actionsConfigMock } from '../actions_config.mock'; +import { licenseStateMock } from '../lib/license_state.mock'; +import { licensingMock } from '@kbn/licensing-plugin/server/mocks'; +import { + httpServerMock, + elasticsearchServiceMock, + savedObjectsClientMock, +} from '@kbn/core/server/mocks'; +import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; +import { usageCountersServiceMock } from '@kbn/usage-collection-plugin/server/usage_counters/usage_counters_service.mock'; +import { actionExecutorMock } from '../lib/action_executor.mock'; +import { ActionsAuthorization } from '../authorization/actions_authorization'; +import { actionsAuthorizationMock } from '../authorization/actions_authorization.mock'; +import { connectorTokenClientMock } from '../lib/connector_token_client.mock'; +import { inMemoryMetricsMock } from '../monitoring/in_memory_metrics.mock'; + +jest.mock('uuid', () => ({ + v4: () => ConnectorSavedObject.id, +})); + +const kibanaIndices = ['.kibana']; +const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); +const scopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); +const actionExecutor = actionExecutorMock.create(); +const authorization = actionsAuthorizationMock.create(); +const ephemeralExecutionEnqueuer = jest.fn(); +const bulkExecutionEnqueuer = jest.fn(); +const request = httpServerMock.createKibanaRequest(); +const auditLogger = auditLoggerMock.create(); +const mockUsageCountersSetup = usageCountersServiceMock.createSetupContract(); +const mockUsageCounter = mockUsageCountersSetup.createUsageCounter('test'); +const mockTaskManager = taskManagerMock.createSetup(); +const getEventLogClient = jest.fn(); +const preSaveHook = jest.fn(); +const postSaveHook = jest.fn(); +const postDeleteHook = jest.fn(); + +let actionsClient: ActionsClient; +let mockedLicenseState: jest.Mocked; +let actionTypeRegistry: ActionTypeRegistry; +let actionTypeRegistryParams: ActionTypeRegistryOpts; +const executor: ExecutorType<{}, {}, {}, void> = async (options) => { + return { status: 'ok', actionId: options.actionId }; +}; + +const ConnectorSavedObject = { + id: 'connector-id-uuid', + type: 'action', + attributes: { + actionTypeId: 'hooked-action-type', + isMissingSecrets: false, + name: 'Hooked Action', + config: { foo: 42 }, + secrets: { bar: 2001 }, + }, + references: [], +}; + +const CreateParms = { + action: { + name: ConnectorSavedObject.attributes.name, + actionTypeId: ConnectorSavedObject.attributes.actionTypeId, + config: ConnectorSavedObject.attributes.config, + secrets: ConnectorSavedObject.attributes.secrets, + }, +}; + +const UpdateParms = { + id: ConnectorSavedObject.id, + action: { + name: ConnectorSavedObject.attributes.name, + config: ConnectorSavedObject.attributes.config, + secrets: ConnectorSavedObject.attributes.secrets, + }, +}; + +const CoreHookParams = { + connectorId: ConnectorSavedObject.id, + config: ConnectorSavedObject.attributes.config, + secrets: ConnectorSavedObject.attributes.secrets, + request, + services: { + // this will be checked with a function test + scopedClusterClient: expect.any(Object), + }, +}; + +const connectorTokenClient = connectorTokenClientMock.create(); +const inMemoryMetrics = inMemoryMetricsMock.create(); + +let logger: MockedLogger; + +beforeEach(() => { + jest.resetAllMocks(); + logger = loggerMock.create(); + mockedLicenseState = licenseStateMock.create(); + + actionTypeRegistryParams = { + licensing: licensingMock.createSetup(), + taskManager: mockTaskManager, + taskRunnerFactory: new TaskRunnerFactory( + new ActionExecutor({ isESOCanEncrypt: true }), + inMemoryMetrics + ), + actionsConfigUtils: actionsConfigMock.create(), + licenseState: mockedLicenseState, + inMemoryConnectors: [], + }; + + actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); + actionsClient = new ActionsClient({ + logger, + actionTypeRegistry, + unsecuredSavedObjectsClient, + scopedClusterClient, + kibanaIndices, + inMemoryConnectors: [], + actionExecutor, + ephemeralExecutionEnqueuer, + bulkExecutionEnqueuer, + request, + authorization: authorization as unknown as ActionsAuthorization, + auditLogger, + usageCounter: mockUsageCounter, + connectorTokenClient, + getEventLogClient, + }); + + actionTypeRegistry.register({ + id: 'hooked-action-type', + name: 'Hooked action type', + minimumLicenseRequired: 'gold', + supportedFeatureIds: ['alerting'], + validate: { + config: { schema: schema.object({ foo: schema.number() }) }, + secrets: { schema: schema.object({ bar: schema.number() }) }, + params: { schema: schema.object({}) }, + }, + executor, + preSaveHook, + postSaveHook, + postDeleteHook, + }); +}); + +describe('connector type hooks', () => { + describe('successful operation and successful hook', () => { + test('for create', async () => { + unsecuredSavedObjectsClient.create.mockResolvedValueOnce(ConnectorSavedObject); + const result = await actionsClient.create(CreateParms); + expect(result.id).toBe(ConnectorSavedObject.id); + + const preParams = { ...CoreHookParams, logger, isUpdate: false }; + const postParams = { ...preParams, wasSuccessful: true }; + + expect(preSaveHook).toHaveBeenCalledTimes(1); + expect(preSaveHook.mock.calls[0]).toStrictEqual([preParams]); + + expect(postSaveHook).toHaveBeenCalledTimes(1); + expect(postSaveHook.mock.calls[0]).toStrictEqual([postParams]); + }); + + test('for update', async () => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(ConnectorSavedObject); + unsecuredSavedObjectsClient.create.mockResolvedValueOnce(ConnectorSavedObject); + const result = await actionsClient.update(UpdateParms); + expect(result.id).toBe(ConnectorSavedObject.id); + + const preParams = { ...CoreHookParams, logger, isUpdate: true }; + const postParams = { ...preParams, wasSuccessful: true }; + + expect(preSaveHook).toHaveBeenCalledTimes(1); + expect(preSaveHook.mock.calls[0]).toStrictEqual([preParams]); + + expect(postSaveHook).toHaveBeenCalledTimes(1); + expect(postSaveHook.mock.calls[0]).toStrictEqual([postParams]); + }); + + test('for delete', async () => { + const expectedResult = Symbol(); + unsecuredSavedObjectsClient.delete.mockResolvedValueOnce(expectedResult); + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(ConnectorSavedObject); + + const result = await actionsClient.delete({ id: ConnectorSavedObject.id }); + expect(result).toBe(expectedResult); + + const postParamsWithSecrets = { ...CoreHookParams, logger }; + const postParams = omit(postParamsWithSecrets, 'secrets'); + + expect(postDeleteHook).toHaveBeenCalledTimes(1); + expect(postDeleteHook.mock.calls[0]).toEqual([postParams]); + }); + }); + + describe('unsuccessful operation and successful hook', () => { + test('for create', async () => { + unsecuredSavedObjectsClient.create.mockRejectedValueOnce(new Error('OMG create')); + await expect(actionsClient.create(CreateParms)).rejects.toMatchInlineSnapshot( + `[Error: OMG create]` + ); + + const preParams = { ...CoreHookParams, logger, isUpdate: false }; + const postParams = { ...preParams, wasSuccessful: false }; + + expect(preSaveHook).toHaveBeenCalledTimes(1); + expect(preSaveHook.mock.calls[0]).toStrictEqual([preParams]); + + expect(postSaveHook).toHaveBeenCalledTimes(1); + expect(postSaveHook.mock.calls[0]).toStrictEqual([postParams]); + }); + + test('for update', async () => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(ConnectorSavedObject); + unsecuredSavedObjectsClient.create.mockRejectedValueOnce(new Error('OMG update')); + await expect(actionsClient.update(UpdateParms)).rejects.toMatchInlineSnapshot( + `[Error: OMG update]` + ); + + const preParams = { ...CoreHookParams, logger, isUpdate: true }; + const postParams = { ...preParams, wasSuccessful: false }; + + expect(preSaveHook).toHaveBeenCalledTimes(1); + expect(preSaveHook.mock.calls[0]).toStrictEqual([preParams]); + + expect(postSaveHook).toHaveBeenCalledTimes(1); + expect(postSaveHook.mock.calls[0]).toStrictEqual([postParams]); + }); + + test('for delete', async () => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(ConnectorSavedObject); + unsecuredSavedObjectsClient.delete.mockRejectedValueOnce(new Error('OMG delete')); + + await expect( + actionsClient.delete({ id: ConnectorSavedObject.id }) + ).rejects.toMatchInlineSnapshot(`[Error: OMG delete]`); + + expect(postDeleteHook).toHaveBeenCalledTimes(0); + }); + }); + + describe('successful operation and unsuccessful hook', () => { + test('for create pre hook', async () => { + preSaveHook.mockRejectedValueOnce(new Error('OMG create pre save')); + + await expect(actionsClient.create(CreateParms)).rejects.toMatchInlineSnapshot( + `[Error: OMG create pre save]` + ); + + const preParams = { ...CoreHookParams, logger, isUpdate: false }; + + expect(preSaveHook).toHaveBeenCalledTimes(1); + expect(preSaveHook.mock.calls[0]).toStrictEqual([preParams]); + + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(0); + expect(postSaveHook).toHaveBeenCalledTimes(0); + }); + + test('for create post hook', async () => { + postSaveHook.mockRejectedValueOnce(new Error('OMG create post save')); + + unsecuredSavedObjectsClient.create.mockResolvedValueOnce(ConnectorSavedObject); + const result = await actionsClient.create(CreateParms); + expect(result.id).toBe(ConnectorSavedObject.id); + + const preParams = { ...CoreHookParams, logger, isUpdate: false }; + const postParams = { ...preParams, wasSuccessful: true }; + + expect(preSaveHook).toHaveBeenCalledTimes(1); + expect(preSaveHook.mock.calls[0]).toStrictEqual([preParams]); + + expect(postSaveHook).toHaveBeenCalledTimes(1); + expect(postSaveHook.mock.calls[0]).toStrictEqual([postParams]); + expect(logger.error).toHaveBeenCalledTimes(1); + expect(logger.error.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "postSaveHook create error for connectorId: \\"connector-id-uuid\\"; type: hooked-action-type: OMG create post save", + Object { + "tags": Array [ + "post-save-hook", + "connector-id-uuid", + ], + }, + ], + ] + `); + }); + + test('for update pre hook', async () => { + preSaveHook.mockRejectedValueOnce(new Error('OMG update pre save')); + + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(ConnectorSavedObject); + unsecuredSavedObjectsClient.create.mockResolvedValueOnce(ConnectorSavedObject); + await expect(actionsClient.update(UpdateParms)).rejects.toMatchInlineSnapshot( + `[Error: OMG update pre save]` + ); + + const preParams = { ...CoreHookParams, logger, isUpdate: true }; + + expect(preSaveHook).toHaveBeenCalledTimes(1); + expect(preSaveHook.mock.calls[0]).toStrictEqual([preParams]); + + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(0); + expect(postSaveHook).toHaveBeenCalledTimes(0); + }); + + test('for update post hook', async () => { + postSaveHook.mockRejectedValueOnce(new Error('OMG update post save')); + + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(ConnectorSavedObject); + unsecuredSavedObjectsClient.create.mockResolvedValueOnce(ConnectorSavedObject); + const result = await actionsClient.update(UpdateParms); + expect(result.id).toBe(ConnectorSavedObject.id); + + const preParams = { ...CoreHookParams, logger, isUpdate: true }; + const postParams = { ...preParams, wasSuccessful: true }; + + expect(preSaveHook).toHaveBeenCalledTimes(1); + expect(preSaveHook.mock.calls[0]).toStrictEqual([preParams]); + + expect(postSaveHook).toHaveBeenCalledTimes(1); + expect(postSaveHook.mock.calls[0]).toStrictEqual([postParams]); + expect(logger.error).toHaveBeenCalledTimes(1); + expect(logger.error.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "postSaveHook update error for connectorId: \\"connector-id-uuid\\"; type: hooked-action-type: OMG update post save", + Object { + "tags": Array [ + "post-save-hook", + "connector-id-uuid", + ], + }, + ], + ] + `); + }); + + test('for delete post hook', async () => { + postDeleteHook.mockRejectedValueOnce(new Error('OMG delete post delete')); + + const expectedResult = Symbol(); + unsecuredSavedObjectsClient.delete.mockResolvedValueOnce(expectedResult); + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(ConnectorSavedObject); + + const result = await actionsClient.delete({ id: ConnectorSavedObject.id }); + expect(result).toBe(expectedResult); + + const postParamsWithSecrets = { ...CoreHookParams, logger }; + const postParams = omit(postParamsWithSecrets, 'secrets'); + + expect(postDeleteHook).toHaveBeenCalledTimes(1); + expect(postDeleteHook.mock.calls[0]).toEqual([postParams]); + expect(logger.error).toHaveBeenCalledTimes(1); + expect(logger.error.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "The post delete hook failed for for connector \\"connector-id-uuid\\": OMG delete post delete", + Object { + "tags": Array [ + "post-delete-hook", + "connector-id-uuid", + ], + }, + ], + ] + `); + }); + }); +}); diff --git a/x-pack/plugins/actions/server/application/connector/methods/update/update.ts b/x-pack/plugins/actions/server/application/connector/methods/update/update.ts index 7baa099a29029..e22715c31d149 100644 --- a/x-pack/plugins/actions/server/application/connector/methods/update/update.ts +++ b/x-pack/plugins/actions/server/application/connector/methods/update/update.ts @@ -15,7 +15,8 @@ import { PreconfiguredActionDisabledModificationError } from '../../../../lib/er import { ConnectorAuditAction, connectorAuditEvent } from '../../../../lib/audit_events'; import { validateConfig, validateConnector, validateSecrets } from '../../../../lib'; import { isConnectorDeprecated } from '../../lib'; -import { RawAction } from '../../../../types'; +import { RawAction, HookServices } from '../../../../types'; +import { tryCatch } from '../../../../lib'; export async function update({ context, id, action }: ConnectorUpdateParams): Promise { try { @@ -75,6 +76,33 @@ export async function update({ context, id, action }: ConnectorUpdateParams): Pr context.actionTypeRegistry.ensureActionTypeEnabled(actionTypeId); + const hookServices: HookServices = { + scopedClusterClient: context.scopedClusterClient, + }; + + if (actionType.preSaveHook) { + try { + await actionType.preSaveHook({ + connectorId: id, + config, + secrets, + logger: context.logger, + request: context.request, + services: hookServices, + isUpdate: true, + }); + } catch (error) { + context.auditLogger?.log( + connectorAuditEvent({ + action: ConnectorAuditAction.UPDATE, + savedObject: { type: 'action', id }, + error, + }) + ); + throw error; + } + } + context.auditLogger?.log( connectorAuditEvent({ action: ConnectorAuditAction.UPDATE, @@ -83,27 +111,57 @@ export async function update({ context, id, action }: ConnectorUpdateParams): Pr }) ); - const result = await context.unsecuredSavedObjectsClient.create( - 'action', - { - ...attributes, - actionTypeId, - name, - isMissingSecrets: false, - config: validatedActionTypeConfig as SavedObjectAttributes, - secrets: validatedActionTypeSecrets as SavedObjectAttributes, - }, - omitBy( - { - id, - overwrite: true, - references, - version, - }, - isUndefined - ) + const result = await tryCatch( + async () => + await context.unsecuredSavedObjectsClient.create( + 'action', + { + ...attributes, + actionTypeId, + name, + isMissingSecrets: false, + config: validatedActionTypeConfig as SavedObjectAttributes, + secrets: validatedActionTypeSecrets as SavedObjectAttributes, + }, + omitBy( + { + id, + overwrite: true, + references, + version, + }, + isUndefined + ) + ) ); + 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: context.logger, + request: context.request, + services: hookServices, + isUpdate: true, + wasSuccessful, + }); + } catch (err) { + context.logger.error(`postSaveHook update error for ${label}: ${err.message}`, { + tags, + }); + } + } + + if (!wasSuccessful) { + throw result; + } + try { await context.connectorTokenClient.deleteConnectorTokens({ connectorId: id }); } catch (e) { diff --git a/x-pack/plugins/actions/server/lib/index.ts b/x-pack/plugins/actions/server/lib/index.ts index 9b8d452f446a9..e13fb85008a84 100644 --- a/x-pack/plugins/actions/server/lib/index.ts +++ b/x-pack/plugins/actions/server/lib/index.ts @@ -38,3 +38,4 @@ export { export { parseDate } from './parse_date'; export type { RelatedSavedObjects } from './related_saved_objects'; export { getBasicAuthHeader, combineHeadersWithBasicAuthHeader } from './get_basic_auth_header'; +export { tryCatch } from './try_catch'; diff --git a/x-pack/plugins/actions/server/lib/try_catch.ts b/x-pack/plugins/actions/server/lib/try_catch.ts new file mode 100644 index 0000000000000..a9932601c8256 --- /dev/null +++ b/x-pack/plugins/actions/server/lib/try_catch.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +// functional version of try/catch, allows you to not have to use +// `let` vars initialied to `undefined` to capture the result value + +export 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,35 @@ 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; + logger: Logger; + services: HookServices; + request: KibanaRequest; +} + export interface SubActionConnectorType { id: string; name: string; @@ -92,6 +122,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..d7c3497edc376 100644 --- a/x-pack/plugins/actions/server/types.ts +++ b/x-pack/plugins/actions/server/types.ts @@ -16,6 +16,7 @@ import { SavedObjectReference, Logger, ISavedObjectsRepository, + IScopedClusterClient, } from '@kbn/core/server'; import { AnySchema } from 'joi'; import { SubActionConnector } from './sub_action_framework/sub_action_connector'; @@ -57,6 +58,10 @@ export interface UnsecuredServices { connectorTokenClient: ConnectorTokenClient; } +export interface HookServices { + scopedClusterClient: IScopedClusterClient; +} + export interface ActionsApiRequestHandlerContext { getActionsClient: () => ActionsClient; listTypes: ActionTypeRegistry['list']; @@ -138,6 +143,44 @@ 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; + 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/config.ts b/x-pack/test/alerting_api_integration/common/config.ts index fb0194b01be99..3ff3def3f4b70 100644 --- a/x-pack/test/alerting_api_integration/common/config.ts +++ b/x-pack/test/alerting_api_integration/common/config.ts @@ -77,6 +77,7 @@ const enabledActionTypes = [ 'test.system-action', 'test.system-action-kibana-privileges', 'test.system-action-connector-adapter', + 'test.connector-with-hooks', ]; export function createTestConfig(name: string, options: CreateTestConfigOptions) { 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..8d5caf79a4c89 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,96 @@ function getIndexRecordActionType() { return result; } +function getHookedActionType() { + const paramsSchema = schema.object({}); + type ParamsType = TypeOf; + const configSchema = schema.object({ + index: schema.string(), + source: 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: 'gold', + 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({ connectorId, config, secrets, services, isUpdate, logger }) { + const body = { + state: { + connectorId, + config, + secrets, + isUpdate, + }, + reference: 'pre-save', + source: config.source, + }; + logger.info(`running hook pre-save for ${JSON.stringify(body)}`); + await services.scopedClusterClient.asInternalUser.index({ + index: config.index, + refresh: 'wait_for', + body, + }); + }, + async postSaveHook({ + connectorId, + config, + secrets, + services, + logger, + isUpdate, + wasSuccessful, + }) { + const body = { + state: { + connectorId, + config, + secrets, + isUpdate, + wasSuccessful, + }, + reference: 'post-save', + source: config.source, + }; + logger.info(`running hook post-save for ${JSON.stringify(body)}`); + await services.scopedClusterClient.asInternalUser.index({ + index: config.index, + refresh: 'wait_for', + body, + }); + }, + async postDeleteHook({ connectorId, config, services, logger }) { + const body = { + state: { + connectorId, + config, + }, + reference: 'post-delete', + source: config.source, + }; + logger.info(`running hook post-delete for ${JSON.stringify(body)}`); + await services.scopedClusterClient.asInternalUser.index({ + index: config.index, + refresh: 'wait_for', + body, + }); + }, + }; + 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..e05a1ea9e0350 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 @@ -7,6 +7,7 @@ import { v4 as uuidv4 } from 'uuid'; import expect from '@kbn/expect'; +import { ESTestIndexTool, ES_TEST_INDEX_NAME } from '@kbn/alerting-api-integration-helpers'; import { UserAtSpaceScenarios } from '../../../scenarios'; import { checkAAD, getUrlPrefix, ObjectRemover } from '../../../../common/lib'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; @@ -15,11 +16,21 @@ import { FtrProviderContext } from '../../../../common/ftr_provider_context'; export default function createActionTests({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const supertestWithoutAuth = getService('supertestWithoutAuth'); + const es = getService('es'); + const retry = getService('retry'); + const esTestIndexTool = new ESTestIndexTool(es, retry); describe('create', () => { const objectRemover = new ObjectRemover(supertest); - after(() => objectRemover.removeAll()); + before(async () => { + await esTestIndexTool.destroy(); + await esTestIndexTool.setup(); + }); + after(async () => { + await esTestIndexTool.destroy(); + await objectRemover.removeAll(); + }); for (const scenario of UserAtSpaceScenarios) { const { user, space } = scenario; @@ -396,6 +407,74 @@ export default function createActionTests({ getService }: FtrProviderContext) { throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); } }); + + it('should handle save hooks appropriately', async () => { + const source = uuidv4(); + const encryptedValue = 'This value should be encrypted'; + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(space.id)}/api/actions/connector`) + .auth(user.username, user.password) + .set('kbn-xsrf', 'foo') + .send({ + name: 'Hooked action', + connector_type_id: 'test.connector-with-hooks', + config: { + index: ES_TEST_INDEX_NAME, + source, + }, + secrets: { + encrypted: encryptedValue, + }, + }); + + const searchResult = await esTestIndexTool.search(source); + + 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(searchResult.body.hits.hits.length).to.eql(0); + 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'); + + const refs: string[] = []; + for (const hit of searchResult.body.hits.hits) { + const doc = hit._source as any; + + const reference = doc.reference; + delete doc.reference; + refs.push(reference); + + if (reference === 'post-save') { + expect(doc.state.wasSuccessful).to.be(true); + delete doc.state.wasSuccessful; + } + + const expected = { + state: { + connectorId: response.body.id, + config: { index: ES_TEST_INDEX_NAME, source }, + secrets: { encrypted: encryptedValue }, + isUpdate: false, + }, + source, + }; + expect(doc).to.eql(expected); + } + + refs.sort(); + expect(refs).to.eql(['post-save', 'pre-save']); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); }); } }); 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..edb9821418f8d 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,7 +5,9 @@ * 2.0. */ +import { v4 as uuidv4 } from 'uuid'; import expect from '@kbn/expect'; +import { ESTestIndexTool, ES_TEST_INDEX_NAME } from '@kbn/alerting-api-integration-helpers'; import { UserAtSpaceScenarios } from '../../../scenarios'; import { getUrlPrefix, ObjectRemover } from '../../../../common/lib'; @@ -15,11 +17,21 @@ import { FtrProviderContext } from '../../../../common/ftr_provider_context'; export default function deleteActionTests({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const supertestWithoutAuth = getService('supertestWithoutAuth'); + const es = getService('es'); + const retry = getService('retry'); + const esTestIndexTool = new ESTestIndexTool(es, retry); describe('delete', () => { const objectRemover = new ObjectRemover(supertest); - after(() => objectRemover.removeAll()); + before(async () => { + await esTestIndexTool.destroy(); + await esTestIndexTool.setup(); + }); + after(async () => { + await esTestIndexTool.destroy(); + await objectRemover.removeAll(); + }); for (const scenario of UserAtSpaceScenarios) { const { user, space } = scenario; @@ -212,6 +224,77 @@ export default function deleteActionTests({ getService }: FtrProviderContext) { throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); } }); + + it('should handle delete hooks appropriately', async () => { + const source = uuidv4(); + const encryptedValue = 'This value should be encrypted'; + const { body: createdAction } = await supertest + .post(`${getUrlPrefix(space.id)}/api/actions/connector`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'Hooked action', + connector_type_id: 'test.connector-with-hooks', + config: { + index: ES_TEST_INDEX_NAME, + source, + }, + secrets: { + encrypted: encryptedValue, + }, + }) + .expect(200); + + // clear out docs from create + await esTestIndexTool.destroy(); + await esTestIndexTool.setup(); + + const response = await supertestWithoutAuth + .delete(`${getUrlPrefix(space.id)}/api/actions/connector/${createdAction.id}`) + .auth(user.username, user.password) + .set('kbn-xsrf', 'foo'); + + const searchResult = await esTestIndexTool.search(source); + + 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(searchResult.body.hits.hits.length).to.eql(0); + 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); + + const refs: string[] = []; + for (const hit of searchResult.body.hits.hits) { + const doc = hit._source as any; + + const reference = doc.reference; + delete doc.reference; + refs.push(reference); + + const expected = { + state: { + connectorId: createdAction.id, + config: { index: ES_TEST_INDEX_NAME, source }, + }, + source, + }; + expect(doc).to.eql(expected); + } + + refs.sort(); + expect(refs).to.eql(['post-delete']); + break; + default: + objectRemover.add(space.id, createdAction.id, 'action', 'actions'); + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); }); } }); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/update.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/update.ts index 7c3c00534f11d..cb9fe8a94c8c0 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/update.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/update.ts @@ -5,7 +5,10 @@ * 2.0. */ +import { v4 as uuidv4 } from 'uuid'; import expect from '@kbn/expect'; +import { ESTestIndexTool, ES_TEST_INDEX_NAME } from '@kbn/alerting-api-integration-helpers'; + import { UserAtSpaceScenarios } from '../../../scenarios'; import { checkAAD, getUrlPrefix, ObjectRemover } from '../../../../common/lib'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; @@ -14,11 +17,21 @@ import { FtrProviderContext } from '../../../../common/ftr_provider_context'; export default function updateActionTests({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const supertestWithoutAuth = getService('supertestWithoutAuth'); + const es = getService('es'); + const retry = getService('retry'); + const esTestIndexTool = new ESTestIndexTool(es, retry); describe('update', () => { const objectRemover = new ObjectRemover(supertest); - after(() => objectRemover.removeAll()); + before(async () => { + await esTestIndexTool.destroy(); + await esTestIndexTool.setup(); + }); + after(async () => { + await esTestIndexTool.destroy(); + await objectRemover.removeAll(); + }); for (const scenario of UserAtSpaceScenarios) { const { user, space } = scenario; @@ -430,6 +443,94 @@ export default function updateActionTests({ getService }: FtrProviderContext) { throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); } }); + + it('should handle save hooks appropriately', async () => { + const source = uuidv4(); + const encryptedValue = 'This value should be encrypted'; + + const { body: createdAction } = await supertest + .post(`${getUrlPrefix(space.id)}/api/actions/connector`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'Hooked action', + connector_type_id: 'test.connector-with-hooks', + config: { + index: ES_TEST_INDEX_NAME, + source, + }, + secrets: { + encrypted: encryptedValue, + }, + }) + .expect(200); + objectRemover.add(space.id, createdAction.id, 'action', 'actions'); + + // clear out docs from create + await esTestIndexTool.destroy(); + await esTestIndexTool.setup(); + + const response = await supertestWithoutAuth + .put(`${getUrlPrefix(space.id)}/api/actions/connector/${createdAction.id}`) + .auth(user.username, user.password) + .set('kbn-xsrf', 'foo') + .send({ + name: 'Hooked action', + config: { + index: ES_TEST_INDEX_NAME, + source, + }, + secrets: { + encrypted: encryptedValue, + }, + }); + + const searchResult = await esTestIndexTool.search(source); + + 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(searchResult.body.hits.hits.length).to.eql(0); + 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); + + const refs: string[] = []; + for (const hit of searchResult.body.hits.hits) { + const doc = hit._source as any; + + const reference = doc.reference; + delete doc.reference; + refs.push(reference); + + if (reference === 'post-save') { + expect(doc.state.wasSuccessful).to.be(true); + delete doc.state.wasSuccessful; + } + + const expected = { + state: { + connectorId: response.body.id, + config: { index: ES_TEST_INDEX_NAME, source }, + secrets: { encrypted: encryptedValue }, + isUpdate: true, + }, + source, + }; + expect(doc).to.eql(expected); + } + + refs.sort(); + expect(refs).to.eql(['post-save', 'pre-save']); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); }); } });