diff --git a/x-pack/legacy/plugins/actions/README.md b/x-pack/legacy/plugins/actions/README.md index 8b1be7f6cbb35d..483d3e8b0739dd 100644 --- a/x-pack/legacy/plugins/actions/README.md +++ b/x-pack/legacy/plugins/actions/README.md @@ -53,7 +53,7 @@ This is the primary function for an action type. Whenever the action needs to ex |config|The decrypted configuration given to an action. This comes from the action saved object that is partially or fully encrypted within the data store. If you would like to validate the config before being passed to the executor, define `validate.config` within the action type.| |params|Parameters for the execution. These will be given at execution time by either an alert or manually provided when calling the plugin provided execute function.| |services.callCluster(path, opts)|Use this to do Elasticsearch queries on the cluster Kibana connects to. This function is the same as any other `callCluster` in Kibana.

**NOTE**: This currently authenticates as the Kibana internal user, but will change in a future PR.| -|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.

**NOTE**: This currently only works when security is disabled. A future PR will add support for enabling security using Elasticsearch API tokens.| +|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)| ### Example @@ -146,6 +146,7 @@ The following table describes the properties of the `options` object. |id|The id of the action you want to execute.|string| |params|The `params` value to give the action type executor.|object| |spaceId|The space id the action is within.|string| +|apiKey|The Elasticsearch API key to use for context. (Note: only required and used when security is enabled).|string| ### Example diff --git a/x-pack/legacy/plugins/actions/mappings.json b/x-pack/legacy/plugins/actions/mappings.json index e2649568f25ec3..480462eb4a63f1 100644 --- a/x-pack/legacy/plugins/actions/mappings.json +++ b/x-pack/legacy/plugins/actions/mappings.json @@ -15,5 +15,19 @@ "type": "binary" } } + }, + "action_task_params": { + "properties": { + "actionId": { + "type": "keyword" + }, + "params": { + "enabled": false, + "type": "object" + }, + "apiKey": { + "type": "binary" + } + } } } diff --git a/x-pack/legacy/plugins/actions/server/action_type_registry.test.ts b/x-pack/legacy/plugins/actions/server/action_type_registry.test.ts index dcb8b8b299d1a2..61fa59f9813ae5 100644 --- a/x-pack/legacy/plugins/actions/server/action_type_registry.test.ts +++ b/x-pack/legacy/plugins/actions/server/action_type_registry.test.ts @@ -26,6 +26,7 @@ function getServices() { } const actionTypeRegistryParams = { getServices, + isSecurityEnabled: true, taskManager: mockTaskManager, encryptedSavedObjectsPlugin: encryptedSavedObjectsMock.create(), spaceIdToNamespace: jest.fn().mockReturnValue(undefined), @@ -64,11 +65,14 @@ describe('register()', () => { }, ] `); - expect(getCreateTaskRunnerFunction).toHaveBeenCalledTimes(1); - const call = getCreateTaskRunnerFunction.mock.calls[0][0]; - expect(call.actionTypeRegistry).toBeTruthy(); - expect(call.encryptedSavedObjectsPlugin).toBeTruthy(); - expect(call.getServices).toBeTruthy(); + expect(getCreateTaskRunnerFunction).toHaveBeenCalledWith({ + actionTypeRegistry, + isSecurityEnabled: true, + encryptedSavedObjectsPlugin: actionTypeRegistryParams.encryptedSavedObjectsPlugin, + getServices: actionTypeRegistryParams.getServices, + getBasePath: actionTypeRegistryParams.getBasePath, + spaceIdToNamespace: actionTypeRegistryParams.spaceIdToNamespace, + }); }); test('throws error if action type already registered', () => { @@ -104,7 +108,6 @@ describe('register()', () => { expect(getRetry(0, new Error())).toEqual(false); expect(getRetry(0, new ExecutorError('my message', {}, true))).toEqual(true); expect(getRetry(0, new ExecutorError('my message', {}, false))).toEqual(false); - expect(getRetry(0, new ExecutorError('my message', {}, null))).toEqual(false); expect(getRetry(0, new ExecutorError('my message', {}, undefined))).toEqual(false); expect(getRetry(0, new ExecutorError('my message', {}, retryTime))).toEqual(retryTime); }); diff --git a/x-pack/legacy/plugins/actions/server/action_type_registry.ts b/x-pack/legacy/plugins/actions/server/action_type_registry.ts index 635d6188782609..d887facf142772 100644 --- a/x-pack/legacy/plugins/actions/server/action_type_registry.ts +++ b/x-pack/legacy/plugins/actions/server/action_type_registry.ts @@ -6,18 +6,23 @@ import Boom from 'boom'; import { i18n } from '@kbn/i18n'; -import { ActionType, GetServicesFunction } from './types'; import { TaskManager, TaskRunCreatorFunction } from '../../task_manager'; import { getCreateTaskRunnerFunction, ExecutorError } from './lib'; import { EncryptedSavedObjectsPlugin } from '../../encrypted_saved_objects'; -import { SpacesPlugin } from '../../spaces'; +import { + ActionType, + GetBasePathFunction, + GetServicesFunction, + SpaceIdToNamespaceFunction, +} from './types'; interface ConstructorOptions { + isSecurityEnabled: boolean; taskManager: TaskManager; getServices: GetServicesFunction; encryptedSavedObjectsPlugin: EncryptedSavedObjectsPlugin; - spaceIdToNamespace: SpacesPlugin['spaceIdToNamespace']; - getBasePath: SpacesPlugin['getBasePath']; + spaceIdToNamespace: SpaceIdToNamespaceFunction; + getBasePath: GetBasePathFunction; } export class ActionTypeRegistry { @@ -31,9 +36,11 @@ export class ActionTypeRegistry { encryptedSavedObjectsPlugin, spaceIdToNamespace, getBasePath, + isSecurityEnabled, }: ConstructorOptions) { this.taskManager = taskManager; this.taskRunCreatorFunction = getCreateTaskRunnerFunction({ + isSecurityEnabled, getServices, actionTypeRegistry: this, encryptedSavedObjectsPlugin, diff --git a/x-pack/legacy/plugins/actions/server/actions_client.test.ts b/x-pack/legacy/plugins/actions/server/actions_client.test.ts index d724c28d72ea51..6c1d3b2df6d475 100644 --- a/x-pack/legacy/plugins/actions/server/actions_client.test.ts +++ b/x-pack/legacy/plugins/actions/server/actions_client.test.ts @@ -29,6 +29,7 @@ function getServices() { const actionTypeRegistryParams = { getServices, + isSecurityEnabled: true, taskManager: mockTaskManager, encryptedSavedObjectsPlugin: mockEncryptedSavedObjectsPlugin, spaceIdToNamespace: jest.fn().mockReturnValue(undefined), diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/email.test.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/email.test.ts index 7ebb1ff9437504..4989c730db1f23 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/email.test.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/email.test.ts @@ -41,6 +41,7 @@ const mockEncryptedSavedObjectsPlugin = encryptedSavedObjectsMock.create(); beforeAll(() => { actionTypeRegistry = new ActionTypeRegistry({ getServices, + isSecurityEnabled: true, taskManager: taskManagerMock.create(), encryptedSavedObjectsPlugin: mockEncryptedSavedObjectsPlugin, spaceIdToNamespace: jest.fn().mockReturnValue(undefined), diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/es_index.test.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/es_index.test.ts index 301f6613bcfa6a..30464d7b1e507c 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/es_index.test.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/es_index.test.ts @@ -38,6 +38,7 @@ const mockEncryptedSavedObjectsPlugin = encryptedSavedObjectsMock.create(); beforeAll(() => { actionTypeRegistry = new ActionTypeRegistry({ getServices, + isSecurityEnabled: true, taskManager: taskManagerMock.create(), encryptedSavedObjectsPlugin: mockEncryptedSavedObjectsPlugin, spaceIdToNamespace: jest.fn().mockReturnValue(undefined), diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.test.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.test.ts index ef38c5b9106279..761a82e8528a4f 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.test.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.test.ts @@ -33,6 +33,7 @@ const mockEncryptedSavedObjectsPlugin = encryptedSavedObjectsMock.create(); beforeAll(() => { actionTypeRegistry = new ActionTypeRegistry({ getServices, + isSecurityEnabled: true, taskManager: taskManagerMock.create(), encryptedSavedObjectsPlugin: mockEncryptedSavedObjectsPlugin, spaceIdToNamespace: jest.fn().mockReturnValue(undefined), diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/slack.test.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/slack.test.ts index c2cc2d04d4f374..bae7897b44d7d7 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/slack.test.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/slack.test.ts @@ -50,6 +50,7 @@ async function mockSlackExecutor(options: ActionTypeExecutorOptions): Promise { actionTypeRegistry = new ActionTypeRegistry({ getServices, + isSecurityEnabled: true, taskManager: taskManagerMock.create(), encryptedSavedObjectsPlugin: mockEncryptedSavedObjectsPlugin, spaceIdToNamespace: jest.fn().mockReturnValue(undefined), diff --git a/x-pack/legacy/plugins/actions/server/create_execute_function.test.ts b/x-pack/legacy/plugins/actions/server/create_execute_function.test.ts index 176eb7d1c855ab..f2610ae0a4b17e 100644 --- a/x-pack/legacy/plugins/actions/server/create_execute_function.test.ts +++ b/x-pack/legacy/plugins/actions/server/create_execute_function.test.ts @@ -10,16 +10,17 @@ import { SavedObjectsClientMock } from '../../../../../src/core/server/mocks'; const mockTaskManager = taskManagerMock.create(); const savedObjectsClient = SavedObjectsClientMock.create(); -const spaceIdToNamespace = jest.fn(); +const getBasePath = jest.fn(); beforeEach(() => jest.resetAllMocks()); describe('execute()', () => { test('schedules the action with all given parameters', async () => { const executeFn = createExecuteFunction({ + getBasePath, + isSecurityEnabled: true, taskManager: mockTaskManager, - internalSavedObjectsRepository: savedObjectsClient, - spaceIdToNamespace, + getScopedSavedObjectsClient: jest.fn().mockReturnValueOnce(savedObjectsClient), }); savedObjectsClient.get.mockResolvedValueOnce({ id: '123', @@ -29,41 +30,129 @@ describe('execute()', () => { }, references: [], }); - spaceIdToNamespace.mockReturnValueOnce('namespace1'); + savedObjectsClient.create.mockResolvedValueOnce({ + id: '234', + type: 'action_task_params', + attributes: {}, + references: [], + }); await executeFn({ id: '123', params: { baz: false }, spaceId: 'default', + apiKey: Buffer.from('123:abc').toString('base64'), }); expect(mockTaskManager.schedule).toHaveBeenCalledTimes(1); expect(mockTaskManager.schedule.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Object { - "params": Object { - "id": "123", - "params": Object { - "baz": false, - }, - "spaceId": "default", - }, - "scope": Array [ - "actions", - ], - "state": Object {}, - "taskType": "actions:mock-action", - }, - ] - `); - expect(savedObjectsClient.get).toHaveBeenCalledTimes(1); - expect(savedObjectsClient.get.mock.calls[0]).toMatchInlineSnapshot(` Array [ - "action", - "123", Object { - "namespace": "namespace1", + "params": Object { + "actionTaskParamsId": "234", + "spaceId": "default", + }, + "scope": Array [ + "actions", + ], + "state": Object {}, + "taskType": "actions:mock-action", }, ] `); - expect(spaceIdToNamespace).toHaveBeenCalledWith('default'); + expect(savedObjectsClient.get).toHaveBeenCalledWith('action', '123'); + expect(savedObjectsClient.create).toHaveBeenCalledWith('action_task_params', { + actionId: '123', + params: { baz: false }, + apiKey: Buffer.from('123:abc').toString('base64'), + }); + }); + + test('uses API key when provided', async () => { + const getScopedSavedObjectsClient = jest.fn().mockReturnValueOnce(savedObjectsClient); + const executeFn = createExecuteFunction({ + getBasePath, + taskManager: mockTaskManager, + getScopedSavedObjectsClient, + isSecurityEnabled: true, + }); + savedObjectsClient.get.mockResolvedValueOnce({ + id: '123', + type: 'action', + attributes: { + actionTypeId: 'mock-action', + }, + references: [], + }); + savedObjectsClient.create.mockResolvedValueOnce({ + id: '234', + type: 'action_task_params', + attributes: {}, + references: [], + }); + + await executeFn({ + id: '123', + params: { baz: false }, + spaceId: 'default', + apiKey: Buffer.from('123:abc').toString('base64'), + }); + expect(getScopedSavedObjectsClient).toHaveBeenCalledWith({ + getBasePath: expect.anything(), + headers: { + // base64 encoded "123:abc" + authorization: 'ApiKey MTIzOmFiYw==', + }, + }); + }); + + test(`doesn't use API keys when not provided`, async () => { + const getScopedSavedObjectsClient = jest.fn().mockReturnValueOnce(savedObjectsClient); + const executeFn = createExecuteFunction({ + isSecurityEnabled: false, + getBasePath, + taskManager: mockTaskManager, + getScopedSavedObjectsClient, + }); + savedObjectsClient.get.mockResolvedValueOnce({ + id: '123', + type: 'action', + attributes: { + actionTypeId: 'mock-action', + }, + references: [], + }); + savedObjectsClient.create.mockResolvedValueOnce({ + id: '234', + type: 'action_task_params', + attributes: {}, + references: [], + }); + + await executeFn({ + id: '123', + params: { baz: false }, + spaceId: 'default', + }); + expect(getScopedSavedObjectsClient).toHaveBeenCalledWith({ + getBasePath: expect.anything(), + headers: {}, + }); + }); + + test(`throws an error when isSecurityEnabled is true and key not passed in`, async () => { + const executeFn = createExecuteFunction({ + getBasePath, + taskManager: mockTaskManager, + getScopedSavedObjectsClient: jest.fn().mockReturnValueOnce(savedObjectsClient), + isSecurityEnabled: true, + }); + await expect( + executeFn({ + id: '123', + params: { baz: false }, + spaceId: 'default', + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"API key is required. The attribute \\"apiKey\\" is missing."` + ); }); }); diff --git a/x-pack/legacy/plugins/actions/server/create_execute_function.ts b/x-pack/legacy/plugins/actions/server/create_execute_function.ts index 5f4f568c7e3fb4..a7e93d9f1ade86 100644 --- a/x-pack/legacy/plugins/actions/server/create_execute_function.ts +++ b/x-pack/legacy/plugins/actions/server/create_execute_function.ts @@ -6,34 +6,57 @@ import { SavedObjectsClientContract } from 'src/core/server'; import { TaskManager } from '../../task_manager'; -import { SpacesPlugin } from '../../spaces'; +import { GetBasePathFunction } from './types'; interface CreateExecuteFunctionOptions { + isSecurityEnabled: boolean; taskManager: TaskManager; - internalSavedObjectsRepository: SavedObjectsClientContract; - spaceIdToNamespace: SpacesPlugin['spaceIdToNamespace']; + getScopedSavedObjectsClient: (request: any) => SavedObjectsClientContract; + getBasePath: GetBasePathFunction; } export interface ExecuteOptions { id: string; params: Record; spaceId: string; + apiKey?: string; } export function createExecuteFunction({ + getBasePath, taskManager, - internalSavedObjectsRepository, - spaceIdToNamespace, + isSecurityEnabled, + getScopedSavedObjectsClient, }: CreateExecuteFunctionOptions) { - return async function execute({ id, params, spaceId }: ExecuteOptions) { - const namespace = spaceIdToNamespace(spaceId); - const actionSavedObject = await internalSavedObjectsRepository.get('action', id, { namespace }); + return async function execute({ id, params, spaceId, apiKey }: ExecuteOptions) { + const requestHeaders: Record = {}; + + if (isSecurityEnabled && !apiKey) { + throw new Error('API key is required. The attribute "apiKey" is missing.'); + } else if (isSecurityEnabled) { + requestHeaders.authorization = `ApiKey ${apiKey}`; + } + + // Since we're using API keys and accessing elasticsearch can only be done + // via a request, we're faking one with the proper authorization headers. + const fakeRequest: any = { + headers: requestHeaders, + getBasePath: () => getBasePath(spaceId), + }; + + const savedObjectsClient = getScopedSavedObjectsClient(fakeRequest); + const actionSavedObject = await savedObjectsClient.get('action', id); + const actionTaskParamsRecord = await savedObjectsClient.create('action_task_params', { + actionId: id, + params, + apiKey, + }); + await taskManager.schedule({ taskType: `actions:${actionSavedObject.attributes.actionTypeId}`, params: { - id, spaceId, - params, + actionTaskParamsId: actionTaskParamsRecord.id, }, state: {}, scope: ['actions'], diff --git a/x-pack/legacy/plugins/actions/server/init.ts b/x-pack/legacy/plugins/actions/server/init.ts index 9004593f498659..931c8e494668e3 100644 --- a/x-pack/legacy/plugins/actions/server/init.ts +++ b/x-pack/legacy/plugins/actions/server/init.ts @@ -4,11 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ +import Hapi from 'hapi'; import { Legacy } from 'kibana'; +import { TaskManager } from '../../task_manager'; import { ActionsClient } from './actions_client'; import { ActionTypeRegistry } from './action_type_registry'; import { createExecuteFunction } from './create_execute_function'; import { ActionsPlugin, Services } from './types'; +import { EncryptedSavedObjectsPlugin } from '../../encrypted_saved_objects'; import { createRoute, deleteRoute, @@ -22,8 +25,21 @@ import { registerBuiltInActionTypes } from './builtin_action_types'; import { SpacesPlugin } from '../../spaces'; import { createOptionalPlugin } from '../../../server/lib/optional_plugin'; -export function init(server: Legacy.Server) { +// Extend PluginProperties to indicate which plugins are guaranteed to exist +// due to being marked as dependencies +interface Plugins extends Hapi.PluginProperties { + task_manager: TaskManager; + encrypted_saved_objects: EncryptedSavedObjectsPlugin; +} + +interface Server extends Legacy.Server { + plugins: Plugins; +} + +export function init(server: Server) { const config = server.config(); + const taskManager = server.plugins.task_manager; + const { callWithRequest } = server.plugins.elasticsearch.getCluster('admin'); const spaces = createOptionalPlugin( config, 'xpack.spaces', @@ -31,50 +47,67 @@ export function init(server: Legacy.Server) { 'spaces' ); - const { callWithInternalUser } = server.plugins.elasticsearch.getCluster('admin'); - const savedObjectsRepositoryWithInternalUser = server.savedObjects.getSavedObjectsRepository( - callWithInternalUser - ); + server.plugins.xpack_main.registerFeature({ + id: 'actions', + name: 'Actions', + app: ['actions', 'kibana'], + privileges: { + all: { + savedObject: { + all: ['action', 'action_task_params'], + read: [], + }, + ui: [], + api: ['actions-read', 'actions-all'], + }, + read: { + savedObject: { + all: ['action_task_params'], + read: ['action'], + }, + ui: [], + api: ['actions-read'], + }, + }, + }); // Encrypted attributes // - `secrets` properties will be encrypted // - `config` will be included in AAD // - everything else excluded from AAD - server.plugins.encrypted_saved_objects!.registerType({ + server.plugins.encrypted_saved_objects.registerType({ type: 'action', attributesToEncrypt: new Set(['secrets']), attributesToExcludeFromAAD: new Set(['description']), }); + server.plugins.encrypted_saved_objects.registerType({ + type: 'action_task_params', + attributesToEncrypt: new Set(['apiKey']), + }); - function getServices(basePath: string, overwrites: Partial = {}): Services { - // Fake request is here to allow creating a scoped saved objects client - // and use it when security is disabled. This will be replaced when the - // future phase of API tokens is complete. - const fakeRequest: any = { - headers: {}, - getBasePath: () => basePath, - }; + function getServices(request: any): Services { return { - log: server.log.bind(server), - callCluster: callWithInternalUser, - savedObjectsClient: server.savedObjects.getScopedSavedObjectsClient(fakeRequest), - ...overwrites, + log: (...args) => server.log(...args), + callCluster: (...args) => callWithRequest(request, ...args), + savedObjectsClient: server.savedObjects.getScopedSavedObjectsClient(request), }; } + function getBasePath(spaceId?: string): string { + return spaces.isEnabled && spaceId + ? spaces.getBasePath(spaceId) + : ((server.config().get('server.basePath') || '') as string); + } + function spaceIdToNamespace(spaceId?: string): string | undefined { + return spaces.isEnabled && spaceId ? spaces.spaceIdToNamespace(spaceId) : undefined; + } - const taskManager = server.plugins.task_manager!; const actionTypeRegistry = new ActionTypeRegistry({ getServices, - taskManager: taskManager!, - encryptedSavedObjectsPlugin: server.plugins.encrypted_saved_objects!, - getBasePath(...args) { - return spaces.isEnabled - ? spaces.getBasePath(...args) - : server.config().get('server.basePath'); - }, - spaceIdToNamespace(...args) { - return spaces.isEnabled ? spaces.spaceIdToNamespace(...args) : undefined; - }, + taskManager, + encryptedSavedObjectsPlugin: server.plugins.encrypted_saved_objects, + getBasePath, + spaceIdToNamespace, + isSecurityEnabled: config.get('xpack.security.enabled'), }); registerBuiltInActionTypes(actionTypeRegistry); @@ -93,11 +126,10 @@ export function init(server: Legacy.Server) { }); const executeFn = createExecuteFunction({ - taskManager: taskManager!, - internalSavedObjectsRepository: savedObjectsRepositoryWithInternalUser, - spaceIdToNamespace(...args) { - return spaces.isEnabled ? spaces.spaceIdToNamespace(...args) : undefined; - }, + taskManager, + getScopedSavedObjectsClient: server.savedObjects.getScopedSavedObjectsClient, + getBasePath, + isSecurityEnabled: config.get('xpack.security.enabled'), }); // Expose functions to server diff --git a/x-pack/legacy/plugins/actions/server/lib/execute.test.ts b/x-pack/legacy/plugins/actions/server/lib/execute.test.ts index 0f14df6e8c6789..cc17c811d17728 100644 --- a/x-pack/legacy/plugins/actions/server/lib/execute.test.ts +++ b/x-pack/legacy/plugins/actions/server/lib/execute.test.ts @@ -166,3 +166,8 @@ test('throws an error when params is invalid', async () => { message: `error validating action params: [param1]: expected value of type [string] but got [undefined]`, }); }); + +test('throws an error when failing to load action through savedObjectsClient', async () => { + savedObjectsClient.get.mockRejectedValueOnce(new Error('No access')); + await expect(execute(executeParams)).rejects.toThrowErrorMatchingInlineSnapshot(`"No access"`); +}); diff --git a/x-pack/legacy/plugins/actions/server/lib/execute.ts b/x-pack/legacy/plugins/actions/server/lib/execute.ts index 95c4f17dd922ec..d69742f6b7c336 100644 --- a/x-pack/legacy/plugins/actions/server/lib/execute.ts +++ b/x-pack/legacy/plugins/actions/server/lib/execute.ts @@ -4,7 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Services, ActionTypeRegistryContract, ActionTypeExecutorResult } from '../types'; +import { + ActionTypeExecutorResult, + ActionTypeRegistryContract, + RawAction, + Services, +} from '../types'; import { validateParams, validateConfig, validateSecrets } from './validate_with_schema'; import { EncryptedSavedObjectsPlugin } from '../../../encrypted_saved_objects'; @@ -25,11 +30,18 @@ export async function execute({ params, encryptedSavedObjectsPlugin, }: ExecuteOptions): Promise { - // TODO: Ensure user can read the action before processing - const action = await encryptedSavedObjectsPlugin.getDecryptedAsInternalUser('action', actionId, { + // Ensure user can read the action before processing + const { + attributes: { actionTypeId, config, description }, + } = await services.savedObjectsClient.get('action', actionId); + // Only get encrypted attributes here, the remaining attributes can be fetched in + // the savedObjectsClient call + const { + attributes: { secrets }, + } = await encryptedSavedObjectsPlugin.getDecryptedAsInternalUser('action', actionId, { namespace, }); - const actionType = actionTypeRegistry.get(action.attributes.actionTypeId); + const actionType = actionTypeRegistry.get(actionTypeId); let validatedParams; let validatedConfig; @@ -37,15 +49,13 @@ export async function execute({ try { validatedParams = validateParams(actionType, params); - validatedConfig = validateConfig(actionType, action.attributes.config); - validatedSecrets = validateSecrets(actionType, action.attributes.secrets); + validatedConfig = validateConfig(actionType, config); + validatedSecrets = validateSecrets(actionType, secrets); } catch (err) { return { status: 'error', message: err.message, retry: false }; } let result: ActionTypeExecutorResult | null = null; - - const { actionTypeId, description } = action.attributes; const actionLabel = `${actionId} - ${actionTypeId} - ${description}`; try { diff --git a/x-pack/legacy/plugins/actions/server/lib/executor_error.ts b/x-pack/legacy/plugins/actions/server/lib/executor_error.ts index 5e0dee3f3cc2d8..56ccbf14e6a45e 100644 --- a/x-pack/legacy/plugins/actions/server/lib/executor_error.ts +++ b/x-pack/legacy/plugins/actions/server/lib/executor_error.ts @@ -6,8 +6,8 @@ export class ExecutorError extends Error { readonly data?: any; - readonly retry?: null | boolean | Date; - constructor(message?: string, data?: any, retry?: null | boolean | Date) { + readonly retry: boolean | Date; + constructor(message?: string, data?: any, retry: boolean | Date = false) { super(message); this.data = data; this.retry = retry; diff --git a/x-pack/legacy/plugins/actions/server/lib/get_create_task_runner_function.test.ts b/x-pack/legacy/plugins/actions/server/lib/get_create_task_runner_function.test.ts index 4f732afc3ec7fd..79120ad51516e0 100644 --- a/x-pack/legacy/plugins/actions/server/lib/get_create_task_runner_function.test.ts +++ b/x-pack/legacy/plugins/actions/server/lib/get_create_task_runner_function.test.ts @@ -23,35 +23,37 @@ const actionType = { name: '1', executor: jest.fn(), }; +const services = { + log: jest.fn(), + callCluster: jest.fn(), + savedObjectsClient: SavedObjectsClientMock.create(), +}; actionTypeRegistry.get.mockReturnValue(actionType); const getCreateTaskRunnerFunctionParams = { - getServices() { - return { - log: jest.fn(), - callCluster: jest.fn(), - savedObjectsClient: SavedObjectsClientMock.create(), - }; - }, + getServices: jest.fn().mockReturnValue(services), actionTypeRegistry, spaceIdToNamespace, encryptedSavedObjectsPlugin: mockedEncryptedSavedObjectsPlugin, getBasePath: jest.fn().mockReturnValue(undefined), + isSecurityEnabled: true, }; const taskInstanceMock = { runAt: new Date(), state: {}, params: { - id: '2', - params: { baz: true }, spaceId: 'test', + actionTaskParamsId: '3', }, taskType: 'actions:1', }; -beforeEach(() => jest.resetAllMocks()); +beforeEach(() => { + jest.resetAllMocks(); + getCreateTaskRunnerFunctionParams.getServices.mockReturnValue(services); +}); test('executes the task by calling the executor with proper parameters', async () => { const { execute: mockExecute } = jest.requireMock('./execute'); @@ -60,11 +62,26 @@ test('executes the task by calling the executor with proper parameters', async ( mockExecute.mockResolvedValueOnce({ status: 'ok' }); spaceIdToNamespace.mockReturnValueOnce('namespace-test'); + mockedEncryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce({ + id: '3', + type: 'action_task_params', + attributes: { + actionId: '2', + params: { baz: true }, + apiKey: Buffer.from('123:abc').toString('base64'), + }, + references: [], + }); const runnerResult = await runner.run(); expect(runnerResult).toBeUndefined(); expect(spaceIdToNamespace).toHaveBeenCalledWith('test'); + expect(mockedEncryptedSavedObjectsPlugin.getDecryptedAsInternalUser).toHaveBeenCalledWith( + 'action_task_params', + '3', + { namespace: 'namespace-test' } + ); expect(mockExecute).toHaveBeenCalledWith({ namespace: 'namespace-test', actionId: '2', @@ -80,6 +97,16 @@ test('throws an error with suggested retry logic when return status is error', a const createTaskRunner = getCreateTaskRunnerFunction(getCreateTaskRunnerFunctionParams); const runner = createTaskRunner({ taskInstance: taskInstanceMock }); + mockedEncryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce({ + id: '3', + type: 'action_task_params', + attributes: { + actionId: '2', + params: { baz: true }, + apiKey: Buffer.from('123:abc').toString('base64'), + }, + references: [], + }); mockExecute.mockResolvedValueOnce({ status: 'error', message: 'Error message', @@ -96,3 +123,111 @@ test('throws an error with suggested retry logic when return status is error', a expect(e.retry).toEqual(false); } }); + +test('uses API key when provided', async () => { + const { execute: mockExecute } = jest.requireMock('./execute'); + const createTaskRunner = getCreateTaskRunnerFunction(getCreateTaskRunnerFunctionParams); + const runner = createTaskRunner({ taskInstance: taskInstanceMock }); + + mockExecute.mockResolvedValueOnce({ status: 'ok' }); + spaceIdToNamespace.mockReturnValueOnce('namespace-test'); + mockedEncryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce({ + id: '3', + type: 'action_task_params', + attributes: { + actionId: '2', + params: { baz: true }, + apiKey: Buffer.from('123:abc').toString('base64'), + }, + references: [], + }); + + await runner.run(); + + expect(getCreateTaskRunnerFunctionParams.getServices).toHaveBeenCalledWith({ + getBasePath: expect.anything(), + headers: { + // base64 encoded "123:abc" + authorization: 'ApiKey MTIzOmFiYw==', + }, + }); +}); + +test(`doesn't use API key when not provided`, async () => { + const { execute: mockExecute } = jest.requireMock('./execute'); + const createTaskRunner = getCreateTaskRunnerFunction({ + ...getCreateTaskRunnerFunctionParams, + isSecurityEnabled: false, + }); + const runner = createTaskRunner({ taskInstance: taskInstanceMock }); + + mockExecute.mockResolvedValueOnce({ status: 'ok' }); + spaceIdToNamespace.mockReturnValueOnce('namespace-test'); + mockedEncryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce({ + id: '3', + type: 'action_task_params', + attributes: { + actionId: '2', + params: { baz: true }, + }, + references: [], + }); + + await runner.run(); + + expect(getCreateTaskRunnerFunctionParams.getServices).toHaveBeenCalledWith({ + getBasePath: expect.anything(), + headers: {}, + }); +}); + +test(`doesn't use API key when provided and isSecurityEnabled is set to false`, async () => { + const { execute: mockExecute } = jest.requireMock('./execute'); + const createTaskRunner = getCreateTaskRunnerFunction({ + ...getCreateTaskRunnerFunctionParams, + isSecurityEnabled: false, + }); + const runner = createTaskRunner({ taskInstance: taskInstanceMock }); + + mockExecute.mockResolvedValueOnce({ status: 'ok' }); + spaceIdToNamespace.mockReturnValueOnce('namespace-test'); + mockedEncryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce({ + id: '3', + type: 'action_task_params', + attributes: { + actionId: '2', + params: { baz: true }, + apiKey: Buffer.from('123:abc').toString('base64'), + }, + references: [], + }); + + await runner.run(); + + expect(getCreateTaskRunnerFunctionParams.getServices).toHaveBeenCalledWith({ + getBasePath: expect.anything(), + headers: {}, + }); +}); + +test(`throws an error when isSecurityEnabled is true but key isn't provided`, async () => { + const { execute: mockExecute } = jest.requireMock('./execute'); + const createTaskRunner = getCreateTaskRunnerFunction(getCreateTaskRunnerFunctionParams); + const runner = createTaskRunner({ taskInstance: taskInstanceMock }); + + mockExecute.mockResolvedValueOnce({ status: 'ok' }); + spaceIdToNamespace.mockReturnValueOnce('namespace-test'); + mockedEncryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce({ + id: '3', + type: 'action_task_params', + attributes: { + actionId: '2', + params: { baz: true }, + }, + references: [], + }); + + await expect(runner.run()).rejects.toThrowErrorMatchingInlineSnapshot( + `"API key is required. The attribute \\"apiKey\\" is missing."` + ); +}); diff --git a/x-pack/legacy/plugins/actions/server/lib/get_create_task_runner_function.ts b/x-pack/legacy/plugins/actions/server/lib/get_create_task_runner_function.ts index f9398f25ff7fff..41073a194ab3cf 100644 --- a/x-pack/legacy/plugins/actions/server/lib/get_create_task_runner_function.ts +++ b/x-pack/legacy/plugins/actions/server/lib/get_create_task_runner_function.ts @@ -6,17 +6,23 @@ import { execute } from './execute'; import { ExecutorError } from './executor_error'; -import { ActionTypeRegistryContract, GetServicesFunction } from '../types'; import { TaskInstance } from '../../../task_manager'; import { EncryptedSavedObjectsPlugin } from '../../../encrypted_saved_objects'; -import { SpacesPlugin } from '../../../spaces'; +import { + ActionTaskParams, + ActionTypeRegistryContract, + GetBasePathFunction, + GetServicesFunction, + SpaceIdToNamespaceFunction, +} from '../types'; interface CreateTaskRunnerFunctionOptions { getServices: GetServicesFunction; actionTypeRegistry: ActionTypeRegistryContract; encryptedSavedObjectsPlugin: EncryptedSavedObjectsPlugin; - spaceIdToNamespace: SpacesPlugin['spaceIdToNamespace']; - getBasePath: SpacesPlugin['getBasePath']; + spaceIdToNamespace: SpaceIdToNamespaceFunction; + getBasePath: GetBasePathFunction; + isSecurityEnabled: boolean; } interface TaskRunnerOptions { @@ -29,19 +35,42 @@ export function getCreateTaskRunnerFunction({ encryptedSavedObjectsPlugin, spaceIdToNamespace, getBasePath, + isSecurityEnabled, }: CreateTaskRunnerFunctionOptions) { return ({ taskInstance }: TaskRunnerOptions) => { return { run: async () => { - const { spaceId, id, params } = taskInstance.params; + const { spaceId, actionTaskParamsId } = taskInstance.params; const namespace = spaceIdToNamespace(spaceId); - const basePath = getBasePath(spaceId); + + const { + attributes: { actionId, params, apiKey }, + } = await encryptedSavedObjectsPlugin.getDecryptedAsInternalUser( + 'action_task_params', + actionTaskParamsId, + { namespace } + ); + + const requestHeaders: Record = {}; + if (isSecurityEnabled && !apiKey) { + throw new ExecutorError('API key is required. The attribute "apiKey" is missing.'); + } else if (isSecurityEnabled) { + requestHeaders.authorization = `ApiKey ${apiKey}`; + } + + // Since we're using API keys and accessing elasticsearch can only be done + // via a request, we're faking one with the proper authorization headers. + const fakeRequest: any = { + headers: requestHeaders, + getBasePath: () => getBasePath(spaceId), + }; + const executorResult = await execute({ namespace, actionTypeRegistry, encryptedSavedObjectsPlugin, - actionId: id, - services: getServices(basePath), + actionId, + services: getServices(fakeRequest), params, }); if (executorResult.status === 'error') { @@ -50,7 +79,7 @@ export function getCreateTaskRunnerFunction({ throw new ExecutorError( executorResult.message, executorResult.data, - executorResult.retry + executorResult.retry == null ? false : executorResult.retry ); } }, diff --git a/x-pack/legacy/plugins/actions/server/routes/create.ts b/x-pack/legacy/plugins/actions/server/routes/create.ts index e1367cdf15ba95..e41a85e8135515 100644 --- a/x-pack/legacy/plugins/actions/server/routes/create.ts +++ b/x-pack/legacy/plugins/actions/server/routes/create.ts @@ -28,6 +28,7 @@ export function createRoute(server: Hapi.Server) { method: 'POST', path: `/api/action`, options: { + tags: ['access:actions-all'], validate: { options: { abortEarly: false, diff --git a/x-pack/legacy/plugins/actions/server/routes/delete.ts b/x-pack/legacy/plugins/actions/server/routes/delete.ts index 6a47b4395d9cdb..5c4d5bcf1cef75 100644 --- a/x-pack/legacy/plugins/actions/server/routes/delete.ts +++ b/x-pack/legacy/plugins/actions/server/routes/delete.ts @@ -18,6 +18,7 @@ export function deleteRoute(server: Hapi.Server) { method: 'DELETE', path: `/api/action/{id}`, options: { + tags: ['access:actions-all'], validate: { params: Joi.object() .keys({ diff --git a/x-pack/legacy/plugins/actions/server/routes/execute.test.ts b/x-pack/legacy/plugins/actions/server/routes/execute.test.ts index 7ca856b0a05baa..9d20e845f9e10d 100644 --- a/x-pack/legacy/plugins/actions/server/routes/execute.test.ts +++ b/x-pack/legacy/plugins/actions/server/routes/execute.test.ts @@ -13,7 +13,7 @@ import { executeRoute } from './execute'; const getServices = jest.fn(); -const { server, actionTypeRegistry, savedObjectsClient } = createMockServer(); +const { server, actionTypeRegistry } = createMockServer(); executeRoute({ server, actionTypeRegistry, getServices }); beforeEach(() => jest.resetAllMocks()); @@ -42,13 +42,6 @@ it('executes an action with proper parameters', async () => { expect(statusCode).toBe(200); expect(payload).toBe('{"success":true}'); - expect(savedObjectsClient.get).toHaveBeenCalledTimes(1); - expect(savedObjectsClient.get.mock.calls[0]).toMatchInlineSnapshot(` -Array [ - "action", - "1", -] -`); expect(execute).toHaveBeenCalledTimes(1); const executeCall = execute.mock.calls[0][0]; expect(executeCall.params).toEqual({ diff --git a/x-pack/legacy/plugins/actions/server/routes/execute.ts b/x-pack/legacy/plugins/actions/server/routes/execute.ts index ac06300d68d731..39288c213e328a 100644 --- a/x-pack/legacy/plugins/actions/server/routes/execute.ts +++ b/x-pack/legacy/plugins/actions/server/routes/execute.ts @@ -29,6 +29,7 @@ export function executeRoute({ server, actionTypeRegistry, getServices }: Execut method: 'POST', path: '/api/action/{id}/_execute', options: { + tags: ['access:actions-read'], response: { emptyStatusCode: 204, }, @@ -52,15 +53,12 @@ export function executeRoute({ server, actionTypeRegistry, getServices }: Execut const { id } = request.params; const { params } = request.payload; const namespace = server.plugins.spaces && server.plugins.spaces.getSpaceId(request); - const savedObjectsClient = request.getSavedObjectsClient(); - // Ensure user can read the action and has access to it - await savedObjectsClient.get('action', id); const result = await execute({ params, actionTypeRegistry, actionId: id, namespace: namespace === 'default' ? undefined : namespace, - services: getServices(request.getBasePath(), { savedObjectsClient }), + services: getServices(request), encryptedSavedObjectsPlugin: server.plugins.encrypted_saved_objects!, }); return result; diff --git a/x-pack/legacy/plugins/actions/server/routes/find.ts b/x-pack/legacy/plugins/actions/server/routes/find.ts index 784b2541425fca..2eda74c587403e 100644 --- a/x-pack/legacy/plugins/actions/server/routes/find.ts +++ b/x-pack/legacy/plugins/actions/server/routes/find.ts @@ -30,6 +30,7 @@ export function findRoute(server: Hapi.Server) { method: 'GET', path: `/api/action/_find`, options: { + tags: ['access:actions-read'], validate: { query: Joi.object() .keys({ diff --git a/x-pack/legacy/plugins/actions/server/routes/get.ts b/x-pack/legacy/plugins/actions/server/routes/get.ts index e2c9c006bdf811..ae66e42bc3f656 100644 --- a/x-pack/legacy/plugins/actions/server/routes/get.ts +++ b/x-pack/legacy/plugins/actions/server/routes/get.ts @@ -18,6 +18,7 @@ export function getRoute(server: Hapi.Server) { method: 'GET', path: `/api/action/{id}`, options: { + tags: ['access:actions-read'], validate: { params: Joi.object() .keys({ diff --git a/x-pack/legacy/plugins/actions/server/routes/list_action_types.ts b/x-pack/legacy/plugins/actions/server/routes/list_action_types.ts index 9ff04af72beaa6..fa2be90d25c47f 100644 --- a/x-pack/legacy/plugins/actions/server/routes/list_action_types.ts +++ b/x-pack/legacy/plugins/actions/server/routes/list_action_types.ts @@ -10,6 +10,9 @@ export function listActionTypesRoute(server: Hapi.Server) { server.route({ method: 'GET', path: `/api/action/types`, + options: { + tags: ['access:actions-read'], + }, async handler(request: Hapi.Request) { return request.server.plugins.actions!.listTypes(); }, diff --git a/x-pack/legacy/plugins/actions/server/routes/update.ts b/x-pack/legacy/plugins/actions/server/routes/update.ts index f8c2e7059f7899..4905ae2f2246a2 100644 --- a/x-pack/legacy/plugins/actions/server/routes/update.ts +++ b/x-pack/legacy/plugins/actions/server/routes/update.ts @@ -20,6 +20,7 @@ export function updateRoute(server: Hapi.Server) { method: 'PUT', path: `/api/action/{id}`, options: { + tags: ['access:actions-all'], validate: { options: { abortEarly: false, diff --git a/x-pack/legacy/plugins/actions/server/types.ts b/x-pack/legacy/plugins/actions/server/types.ts index e30287d6dde603..946aa136be5087 100644 --- a/x-pack/legacy/plugins/actions/server/types.ts +++ b/x-pack/legacy/plugins/actions/server/types.ts @@ -4,13 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SavedObjectsClientContract } from 'src/core/server'; +import { SavedObjectsClientContract, SavedObjectAttributes } from 'src/core/server'; import { ActionTypeRegistry } from './action_type_registry'; import { ExecuteOptions } from './create_execute_function'; export type WithoutQueryAndParams = Pick>; -export type GetServicesFunction = (basePath: string, overwrites?: Partial) => Services; +export type GetServicesFunction = (request: any) => Services; export type ActionTypeRegistryContract = PublicMethodsOf; +export type GetBasePathFunction = (spaceId?: string) => string; +export type SpaceIdToNamespaceFunction = (spaceId?: string) => string | undefined; export interface Services { callCluster(path: string, opts: any): Promise; @@ -68,3 +70,16 @@ export interface ActionType { }; executor: ExecutorType; } + +export interface RawAction extends SavedObjectAttributes { + actionTypeId: string; + description: string; + config: SavedObjectAttributes; + secrets: SavedObjectAttributes; +} + +export interface ActionTaskParams extends SavedObjectAttributes { + actionId: string; + params: Record; + apiKey?: string; +} diff --git a/x-pack/legacy/plugins/alerting/README.md b/x-pack/legacy/plugins/alerting/README.md index 18034807b7ac75..b94a81dea20798 100644 --- a/x-pack/legacy/plugins/alerting/README.md +++ b/x-pack/legacy/plugins/alerting/README.md @@ -49,8 +49,8 @@ This is the primary function for an alert type. Whenever the alert needs to exec |Property|Description| |---|---| -|services.callCluster(path, opts)|Use this to do Elasticsearch queries on the cluster Kibana connects to. This function is the same as any other `callCluster` in Kibana.

**NOTE**: This currently authenticates as the Kibana internal user, but will change in a future PR.| -|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.

**NOTE**: This currently only works when security is disabled. A future PR will add support for enabled security using Elasticsearch API tokens.| +|services.callCluster(path, opts)|Use this to do Elasticsearch queries on the cluster Kibana connects to. This function is the same as any other `callCluster` in Kibana but in the context of the user who created the alert when security is enabled.| +|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 who created the alert (only when security isenabled).| |services.log(tags, [data], [timestamp])|Use this to create server logs. (This is the same function as server.log)| |startedAt|The date and time the alert type started execution.| |previousStartedAt|The previous date and time the alert type started a successful execution.| diff --git a/x-pack/legacy/plugins/alerting/index.ts b/x-pack/legacy/plugins/alerting/index.ts index 67a91be7606016..64af7fa671a714 100644 --- a/x-pack/legacy/plugins/alerting/index.ts +++ b/x-pack/legacy/plugins/alerting/index.ts @@ -15,7 +15,7 @@ export function alerting(kibana: any) { return new kibana.Plugin({ id: 'alerting', configPrefix: 'xpack.alerting', - require: ['kibana', 'elasticsearch', 'actions', 'task_manager'], + require: ['kibana', 'elasticsearch', 'actions', 'task_manager', 'encrypted_saved_objects'], isEnabled(config: Legacy.KibanaConfig) { return ( config.get('xpack.alerting.enabled') === true && diff --git a/x-pack/legacy/plugins/alerting/mappings.json b/x-pack/legacy/plugins/alerting/mappings.json index f1274ae8de4023..c6ecbdf905719e 100644 --- a/x-pack/legacy/plugins/alerting/mappings.json +++ b/x-pack/legacy/plugins/alerting/mappings.json @@ -31,6 +31,15 @@ }, "scheduledTaskId": { "type": "keyword" + }, + "createdBy": { + "type": "keyword" + }, + "updatedBy": { + "type": "keyword" + }, + "apiKey": { + "type": "binary" } } } diff --git a/x-pack/legacy/plugins/alerting/server/alert_type_registry.test.ts b/x-pack/legacy/plugins/alerting/server/alert_type_registry.test.ts index cb578442071be6..00707d83fb256e 100644 --- a/x-pack/legacy/plugins/alerting/server/alert_type_registry.test.ts +++ b/x-pack/legacy/plugins/alerting/server/alert_type_registry.test.ts @@ -11,10 +11,12 @@ jest.mock('./lib/get_create_task_runner_function', () => ({ import { AlertTypeRegistry } from './alert_type_registry'; import { SavedObjectsClientMock } from '../../../../../src/core/server/mocks'; import { taskManagerMock } from '../../task_manager/task_manager.mock'; +import { encryptedSavedObjectsMock } from '../../encrypted_saved_objects/server/plugin.mock'; const taskManager = taskManagerMock.create(); const alertTypeRegistryParams = { + isSecurityEnabled: true, getServices() { return { log: jest.fn(), @@ -24,9 +26,9 @@ const alertTypeRegistryParams = { }, taskManager, executeAction: jest.fn(), - internalSavedObjectsRepository: SavedObjectsClientMock.create(), - spaceIdToNamespace: jest.fn().mockReturnValue(undefined), getBasePath: jest.fn().mockReturnValue(undefined), + spaceIdToNamespace: jest.fn().mockReturnValue(undefined), + encryptedSavedObjectsPlugin: encryptedSavedObjectsMock.create(), }; beforeEach(() => jest.resetAllMocks()); @@ -50,15 +52,16 @@ describe('has()', () => { describe('register()', () => { test('registers the executor with the task manager', () => { + const alertType = { + id: 'test', + name: 'Test', + executor: jest.fn(), + }; // eslint-disable-next-line @typescript-eslint/no-var-requires const { getCreateTaskRunnerFunction } = require('./lib/get_create_task_runner_function'); const registry = new AlertTypeRegistry(alertTypeRegistryParams); getCreateTaskRunnerFunction.mockReturnValue(jest.fn()); - registry.register({ - id: 'test', - name: 'Test', - executor: jest.fn(), - }); + registry.register(alertType); expect(taskManager.registerTaskDefinitions).toHaveBeenCalledTimes(1); expect(taskManager.registerTaskDefinitions.mock.calls[0]).toMatchInlineSnapshot(` Array [ @@ -71,19 +74,15 @@ Array [ }, ] `); - expect(getCreateTaskRunnerFunction).toHaveBeenCalledTimes(1); - const firstCall = getCreateTaskRunnerFunction.mock.calls[0][0]; - expect(firstCall.alertType).toMatchInlineSnapshot(` -Object { - "executor": [MockFunction], - "id": "test", - "name": "Test", -} -`); - expect(firstCall.internalSavedObjectsRepository).toBeTruthy(); - expect(firstCall.getBasePath).toBeTruthy(); - expect(firstCall.spaceIdToNamespace).toBeTruthy(); - expect(firstCall.executeAction).toMatchInlineSnapshot(`[MockFunction]`); + expect(getCreateTaskRunnerFunction).toHaveBeenCalledWith({ + alertType, + isSecurityEnabled: true, + getServices: alertTypeRegistryParams.getServices, + encryptedSavedObjectsPlugin: alertTypeRegistryParams.encryptedSavedObjectsPlugin, + getBasePath: alertTypeRegistryParams.getBasePath, + spaceIdToNamespace: alertTypeRegistryParams.spaceIdToNamespace, + executeAction: alertTypeRegistryParams.executeAction, + }); }); test('should throw an error if type is already registered', () => { diff --git a/x-pack/legacy/plugins/alerting/server/alert_type_registry.ts b/x-pack/legacy/plugins/alerting/server/alert_type_registry.ts index e5eda7ad8dd059..da169668cee6c1 100644 --- a/x-pack/legacy/plugins/alerting/server/alert_type_registry.ts +++ b/x-pack/legacy/plugins/alerting/server/alert_type_registry.ts @@ -6,45 +6,53 @@ import Boom from 'boom'; import { i18n } from '@kbn/i18n'; -import { SavedObjectsClientContract } from 'src/core/server'; -import { AlertType, Services } from './types'; import { TaskManager } from '../../task_manager'; import { getCreateTaskRunnerFunction } from './lib'; import { ActionsPlugin } from '../../actions'; -import { SpacesPlugin } from '../../spaces'; +import { EncryptedSavedObjectsPlugin } from '../../encrypted_saved_objects'; +import { + AlertType, + GetBasePathFunction, + GetServicesFunction, + SpaceIdToNamespaceFunction, +} from './types'; interface ConstructorOptions { - getServices: (basePath: string) => Services; + isSecurityEnabled: boolean; + getServices: GetServicesFunction; taskManager: TaskManager; executeAction: ActionsPlugin['execute']; - internalSavedObjectsRepository: SavedObjectsClientContract; - spaceIdToNamespace: SpacesPlugin['spaceIdToNamespace']; - getBasePath: SpacesPlugin['getBasePath']; + encryptedSavedObjectsPlugin: EncryptedSavedObjectsPlugin; + spaceIdToNamespace: SpaceIdToNamespaceFunction; + getBasePath: GetBasePathFunction; } export class AlertTypeRegistry { - private readonly getServices: (basePath: string) => Services; + private readonly getServices: GetServicesFunction; private readonly taskManager: TaskManager; private readonly executeAction: ActionsPlugin['execute']; private readonly alertTypes: Map = new Map(); - private readonly internalSavedObjectsRepository: SavedObjectsClientContract; - private readonly spaceIdToNamespace: SpacesPlugin['spaceIdToNamespace']; - private readonly getBasePath: SpacesPlugin['getBasePath']; + private readonly encryptedSavedObjectsPlugin: EncryptedSavedObjectsPlugin; + private readonly spaceIdToNamespace: SpaceIdToNamespaceFunction; + private readonly getBasePath: GetBasePathFunction; + private readonly isSecurityEnabled: boolean; constructor({ - internalSavedObjectsRepository, + encryptedSavedObjectsPlugin, executeAction, taskManager, getServices, spaceIdToNamespace, getBasePath, + isSecurityEnabled, }: ConstructorOptions) { this.taskManager = taskManager; this.executeAction = executeAction; - this.internalSavedObjectsRepository = internalSavedObjectsRepository; + this.encryptedSavedObjectsPlugin = encryptedSavedObjectsPlugin; this.getServices = getServices; this.getBasePath = getBasePath; this.spaceIdToNamespace = spaceIdToNamespace; + this.isSecurityEnabled = isSecurityEnabled; } public has(id: string) { @@ -69,9 +77,10 @@ export class AlertTypeRegistry { type: `alerting:${alertType.id}`, createTaskRunner: getCreateTaskRunnerFunction({ alertType, + isSecurityEnabled: this.isSecurityEnabled, getServices: this.getServices, executeAction: this.executeAction, - internalSavedObjectsRepository: this.internalSavedObjectsRepository, + encryptedSavedObjectsPlugin: this.encryptedSavedObjectsPlugin, getBasePath: this.getBasePath, spaceIdToNamespace: this.spaceIdToNamespace, }), diff --git a/x-pack/legacy/plugins/alerting/server/alerts_client.test.ts b/x-pack/legacy/plugins/alerting/server/alerts_client.test.ts index c6eae114e725a5..c1ae81ee208ad1 100644 --- a/x-pack/legacy/plugins/alerting/server/alerts_client.test.ts +++ b/x-pack/legacy/plugins/alerting/server/alerts_client.test.ts @@ -20,9 +20,15 @@ const alertsClientParams = { alertTypeRegistry, savedObjectsClient, spaceId: 'default', + getUserName: jest.fn(), + createAPIKey: jest.fn(), }; -beforeEach(() => jest.resetAllMocks()); +beforeEach(() => { + jest.resetAllMocks(); + alertsClientParams.createAPIKey.mockResolvedValue({ created: false }); + alertsClientParams.getUserName.mockResolvedValue('elastic'); +}); const mockedDate = new Date('2019-02-12T21:01:22.479Z'); (global as any).Date = class Date { @@ -119,98 +125,101 @@ describe('create()', () => { }); const result = await alertsClient.create({ data }); expect(result).toMatchInlineSnapshot(` - Object { - "actions": Array [ - Object { - "group": "default", - "id": "1", - "params": Object { - "foo": true, - }, - }, - ], - "alertTypeId": "123", - "alertTypeParams": Object { - "bar": true, - }, - "id": "1", - "interval": "10s", - "scheduledTaskId": "task-123", - } - `); + Object { + "actions": Array [ + Object { + "group": "default", + "id": "1", + "params": Object { + "foo": true, + }, + }, + ], + "alertTypeId": "123", + "alertTypeParams": Object { + "bar": true, + }, + "id": "1", + "interval": "10s", + "scheduledTaskId": "task-123", + } + `); expect(savedObjectsClient.create).toHaveBeenCalledTimes(1); expect(savedObjectsClient.create.mock.calls[0]).toHaveLength(3); expect(savedObjectsClient.create.mock.calls[0][0]).toEqual('alert'); expect(savedObjectsClient.create.mock.calls[0][1]).toMatchInlineSnapshot(` - Object { - "actions": Array [ - Object { - "actionRef": "action_0", - "group": "default", - "params": Object { - "foo": true, - }, - }, - ], - "alertTypeId": "123", - "alertTypeParams": Object { - "bar": true, - }, - "enabled": true, - "interval": "10s", - } - `); - expect(savedObjectsClient.create.mock.calls[0][2]).toMatchInlineSnapshot(` - Object { - "references": Array [ - Object { - "id": "1", - "name": "action_0", - "type": "action", - }, - ], - } - `); - expect(taskManager.schedule).toHaveBeenCalledTimes(1); - expect(taskManager.schedule.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Object { - "params": Object { - "alertId": "1", - "spaceId": "default", - }, - "scope": Array [ - "alerting", - ], - "state": Object { - "alertInstances": Object {}, - "alertTypeState": Object {}, - "previousStartedAt": null, + Object { + "actions": Array [ + Object { + "actionRef": "action_0", + "group": "default", + "params": Object { + "foo": true, + }, }, - "taskType": "alerting:123", + ], + "alertTypeId": "123", + "alertTypeParams": Object { + "bar": true, }, - ] + "apiKey": undefined, + "createdBy": "elastic", + "enabled": true, + "interval": "10s", + "updatedBy": "elastic", + } `); + expect(savedObjectsClient.create.mock.calls[0][2]).toMatchInlineSnapshot(` + Object { + "references": Array [ + Object { + "id": "1", + "name": "action_0", + "type": "action", + }, + ], + } + `); + expect(taskManager.schedule).toHaveBeenCalledTimes(1); + expect(taskManager.schedule.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "params": Object { + "alertId": "1", + "spaceId": "default", + }, + "scope": Array [ + "alerting", + ], + "state": Object { + "alertInstances": Object {}, + "alertTypeState": Object {}, + "previousStartedAt": null, + }, + "taskType": "alerting:123", + }, + ] + `); expect(savedObjectsClient.update).toHaveBeenCalledTimes(1); expect(savedObjectsClient.update.mock.calls[0]).toHaveLength(4); expect(savedObjectsClient.update.mock.calls[0][0]).toEqual('alert'); expect(savedObjectsClient.update.mock.calls[0][1]).toEqual('1'); expect(savedObjectsClient.update.mock.calls[0][2]).toMatchInlineSnapshot(` - Object { - "scheduledTaskId": "task-123", - } - `); + Object { + "scheduledTaskId": "task-123", + } + `); expect(savedObjectsClient.update.mock.calls[0][3]).toMatchInlineSnapshot(` - Object { - "references": Array [ - Object { - "id": "1", - "name": "action_0", - "type": "action", - }, - ], - } - `); + Object { + "references": Array [ + Object { + "id": "1", + "name": "action_0", + "type": "action", + }, + ], + } + `); }); test('creates a disabled alert', async () => { @@ -251,25 +260,25 @@ describe('create()', () => { }); const result = await alertsClient.create({ data }); expect(result).toMatchInlineSnapshot(` - Object { - "actions": Array [ - Object { - "group": "default", - "id": "1", - "params": Object { - "foo": true, - }, - }, - ], - "alertTypeId": "123", - "alertTypeParams": Object { - "bar": true, - }, - "enabled": false, - "id": "1", - "interval": 10000, - } - `); + Object { + "actions": Array [ + Object { + "group": "default", + "id": "1", + "params": Object { + "foo": true, + }, + }, + ], + "alertTypeId": "123", + "alertTypeParams": Object { + "bar": true, + }, + "enabled": false, + "id": "1", + "interval": 10000, + } + `); expect(savedObjectsClient.create).toHaveBeenCalledTimes(1); expect(taskManager.schedule).toHaveBeenCalledTimes(0); }); @@ -350,11 +359,11 @@ describe('create()', () => { ); expect(savedObjectsClient.delete).toHaveBeenCalledTimes(1); expect(savedObjectsClient.delete.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - "alert", - "1", - ] - `); + Array [ + "alert", + "1", + ] + `); }); test('returns task manager error if cleanup fails, logs to console', async () => { @@ -399,14 +408,14 @@ describe('create()', () => { ); expect(alertsClientParams.log).toHaveBeenCalledTimes(1); expect(alertsClientParams.log.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Array [ - "alerting", - "error", - ], - "Failed to cleanup alert \\"1\\" after scheduling task failed. Error: Saved object delete error", - ] - `); + Array [ + Array [ + "alerting", + "error", + ], + "Failed to cleanup alert \\"1\\" after scheduling task failed. Error: Saved object delete error", + ] + `); }); test('throws an error if alert type not registerd', async () => { @@ -419,6 +428,104 @@ describe('create()', () => { `"Invalid type"` ); }); + + test('calls the API key function', async () => { + const alertsClient = new AlertsClient(alertsClientParams); + const data = getMockData(); + alertTypeRegistry.get.mockReturnValueOnce({ + id: '123', + name: 'Test', + async executor() {}, + }); + alertsClientParams.createAPIKey.mockResolvedValueOnce({ + created: true, + result: { id: '123', api_key: 'abc' }, + }); + savedObjectsClient.create.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + alertTypeId: '123', + interval: '10s', + alertTypeParams: { + bar: true, + }, + actions: [ + { + group: 'default', + actionRef: 'action_0', + params: { + foo: true, + }, + }, + ], + }, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + ], + }); + taskManager.schedule.mockResolvedValueOnce({ + id: 'task-123', + taskType: 'alerting:123', + scheduledAt: new Date(), + attempts: 1, + status: 'idle', + runAt: new Date(), + startedAt: null, + retryAt: null, + state: {}, + params: {}, + }); + savedObjectsClient.update.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + scheduledTaskId: 'task-123', + }, + references: [ + { + id: '1', + name: 'action_0', + type: 'action', + }, + ], + }); + await alertsClient.create({ data }); + + expect(alertsClientParams.createAPIKey).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.create).toHaveBeenCalledWith( + 'alert', + { + actions: [ + { + actionRef: 'action_0', + group: 'default', + params: { foo: true }, + }, + ], + alertTypeId: '123', + alertTypeParams: { bar: true }, + apiKey: Buffer.from('123:abc').toString('base64'), + createdBy: 'elastic', + updatedBy: 'elastic', + enabled: true, + interval: '10s', + }, + { + references: [ + { + id: '1', + name: 'action_0', + type: 'action', + }, + ], + } + ); + }); }); describe('enable()', () => { @@ -454,6 +561,8 @@ describe('enable()', () => { { enabled: true, scheduledTaskId: 'task-123', + updatedBy: 'elastic', + apiKey: null, }, { references: [], @@ -491,6 +600,64 @@ describe('enable()', () => { expect(taskManager.schedule).toHaveBeenCalledTimes(0); expect(savedObjectsClient.update).toHaveBeenCalledTimes(0); }); + + test('calls the API key function', async () => { + const alertsClient = new AlertsClient(alertsClientParams); + savedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + interval: '10s', + alertTypeId: '2', + enabled: false, + }, + references: [], + }); + taskManager.schedule.mockResolvedValueOnce({ + id: 'task-123', + scheduledAt: new Date(), + attempts: 0, + status: 'idle', + runAt: new Date(), + state: {}, + params: {}, + taskType: '', + startedAt: null, + retryAt: null, + }); + alertsClientParams.createAPIKey.mockResolvedValueOnce({ + created: true, + result: { id: '123', api_key: 'abc' }, + }); + + await alertsClient.enable({ id: '1' }); + expect(savedObjectsClient.update).toHaveBeenCalledWith( + 'alert', + '1', + { + enabled: true, + scheduledTaskId: 'task-123', + apiKey: Buffer.from('123:abc').toString('base64'), + updatedBy: 'elastic', + }, + { + references: [], + } + ); + expect(taskManager.schedule).toHaveBeenCalledWith({ + taskType: `alerting:2`, + params: { + alertId: '1', + spaceId: 'default', + }, + state: { + alertInstances: {}, + alertTypeState: {}, + previousStartedAt: null, + }, + scope: ['alerting'], + }); + }); }); describe('disable()', () => { @@ -513,8 +680,10 @@ describe('disable()', () => { 'alert', '1', { + apiKey: null, enabled: false, scheduledTaskId: null, + updatedBy: 'elastic', }, { references: [], @@ -575,31 +744,31 @@ describe('get()', () => { }); const result = await alertsClient.get({ id: '1' }); expect(result).toMatchInlineSnapshot(` - Object { - "actions": Array [ - Object { - "group": "default", - "id": "1", - "params": Object { - "foo": true, - }, - }, - ], - "alertTypeId": "123", - "alertTypeParams": Object { - "bar": true, - }, - "id": "1", - "interval": "10s", - } - `); + Object { + "actions": Array [ + Object { + "group": "default", + "id": "1", + "params": Object { + "foo": true, + }, + }, + ], + "alertTypeId": "123", + "alertTypeParams": Object { + "bar": true, + }, + "id": "1", + "interval": "10s", + } + `); expect(savedObjectsClient.get).toHaveBeenCalledTimes(1); expect(savedObjectsClient.get.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - "alert", - "1", - ] - `); + Array [ + "alert", + "1", + ] + `); }); test(`throws an error when references aren't found`, async () => { @@ -670,39 +839,39 @@ describe('find()', () => { }); const result = await alertsClient.find(); expect(result).toMatchInlineSnapshot(` - Object { - "data": Array [ - Object { - "actions": Array [ - Object { - "group": "default", - "id": "1", - "params": Object { - "foo": true, - }, - }, - ], - "alertTypeId": "123", - "alertTypeParams": Object { - "bar": true, - }, - "id": "1", - "interval": "10s", - }, - ], - "page": 1, - "perPage": 10, - "total": 1, - } - `); + Object { + "data": Array [ + Object { + "actions": Array [ + Object { + "group": "default", + "id": "1", + "params": Object { + "foo": true, + }, + }, + ], + "alertTypeId": "123", + "alertTypeParams": Object { + "bar": true, + }, + "id": "1", + "interval": "10s", + }, + ], + "page": 1, + "perPage": 10, + "total": 1, + } + `); expect(savedObjectsClient.find).toHaveBeenCalledTimes(1); expect(savedObjectsClient.find.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Object { - "type": "alert", - }, - ] - `); + Array [ + Object { + "type": "alert", + }, + ] + `); }); }); @@ -744,17 +913,17 @@ describe('delete()', () => { expect(result).toEqual({ success: true }); expect(savedObjectsClient.delete).toHaveBeenCalledTimes(1); expect(savedObjectsClient.delete.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - "alert", - "1", - ] - `); + Array [ + "alert", + "1", + ] + `); expect(taskManager.remove).toHaveBeenCalledTimes(1); expect(taskManager.remove.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - "task-123", - ] - `); + Array [ + "task-123", + ] + `); }); }); @@ -826,58 +995,189 @@ describe('update()', () => { }, }); expect(result).toMatchInlineSnapshot(` - Object { - "actions": Array [ - Object { - "group": "default", - "id": "1", - "params": Object { - "foo": true, - }, - }, - ], - "alertTypeParams": Object { - "bar": true, - }, - "enabled": true, - "id": "1", - "interval": "10s", - "scheduledTaskId": "task-123", - } - `); + Object { + "actions": Array [ + Object { + "group": "default", + "id": "1", + "params": Object { + "foo": true, + }, + }, + ], + "alertTypeParams": Object { + "bar": true, + }, + "enabled": true, + "id": "1", + "interval": "10s", + "scheduledTaskId": "task-123", + } + `); expect(savedObjectsClient.update).toHaveBeenCalledTimes(1); expect(savedObjectsClient.update.mock.calls[0]).toHaveLength(4); expect(savedObjectsClient.update.mock.calls[0][0]).toEqual('alert'); expect(savedObjectsClient.update.mock.calls[0][1]).toEqual('1'); expect(savedObjectsClient.update.mock.calls[0][2]).toMatchInlineSnapshot(` - Object { - "actions": Array [ - Object { - "actionRef": "action_0", - "group": "default", - "params": Object { - "foo": true, - }, - }, - ], - "alertTypeParams": Object { - "bar": true, - }, - "interval": "10s", - } - `); + Object { + "actions": Array [ + Object { + "actionRef": "action_0", + "group": "default", + "params": Object { + "foo": true, + }, + }, + ], + "alertTypeParams": Object { + "bar": true, + }, + "apiKey": null, + "interval": "10s", + "updatedBy": "elastic", + } + `); expect(savedObjectsClient.update.mock.calls[0][3]).toMatchInlineSnapshot(` - Object { - "references": Array [ - Object { - "id": "1", - "name": "action_0", - "type": "action", - }, - ], - "version": "123", - } - `); + Object { + "references": Array [ + Object { + "id": "1", + "name": "action_0", + "type": "action", + }, + ], + "version": "123", + } + `); + }); + + it('calls the createApiKey function', async () => { + const alertsClient = new AlertsClient(alertsClientParams); + alertTypeRegistry.get.mockReturnValueOnce({ + id: '123', + name: 'Test', + async executor() {}, + }); + savedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + enabled: true, + alertTypeId: '123', + scheduledTaskId: 'task-123', + }, + references: [], + }); + alertsClientParams.createAPIKey.mockResolvedValueOnce({ + created: true, + result: { id: '123', api_key: 'abc' }, + }); + savedObjectsClient.update.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + enabled: true, + interval: '10s', + alertTypeParams: { + bar: true, + }, + actions: [ + { + group: 'default', + actionRef: 'action_0', + params: { + foo: true, + }, + }, + ], + apiKey: Buffer.from('123:abc').toString('base64'), + scheduledTaskId: 'task-123', + }, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + ], + }); + const result = await alertsClient.update({ + id: '1', + data: { + interval: '10s', + alertTypeParams: { + bar: true, + }, + actions: [ + { + group: 'default', + id: '1', + params: { + foo: true, + }, + }, + ], + }, + options: { + version: '123', + }, + }); + expect(result).toMatchInlineSnapshot(` + Object { + "actions": Array [ + Object { + "group": "default", + "id": "1", + "params": Object { + "foo": true, + }, + }, + ], + "alertTypeParams": Object { + "bar": true, + }, + "apiKey": "MTIzOmFiYw==", + "enabled": true, + "id": "1", + "interval": "10s", + "scheduledTaskId": "task-123", + } + `); + expect(savedObjectsClient.update).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.update.mock.calls[0]).toHaveLength(4); + expect(savedObjectsClient.update.mock.calls[0][0]).toEqual('alert'); + expect(savedObjectsClient.update.mock.calls[0][1]).toEqual('1'); + expect(savedObjectsClient.update.mock.calls[0][2]).toMatchInlineSnapshot(` + Object { + "actions": Array [ + Object { + "actionRef": "action_0", + "group": "default", + "params": Object { + "foo": true, + }, + }, + ], + "alertTypeParams": Object { + "bar": true, + }, + "apiKey": "MTIzOmFiYw==", + "interval": "10s", + "updatedBy": "elastic", + } + `); + expect(savedObjectsClient.update.mock.calls[0][3]).toMatchInlineSnapshot(` + Object { + "references": Array [ + Object { + "id": "1", + "name": "action_0", + "type": "action", + }, + ], + "version": "123", + } + `); }); it('should validate alertTypeParams', async () => { diff --git a/x-pack/legacy/plugins/alerting/server/alerts_client.ts b/x-pack/legacy/plugins/alerting/server/alerts_client.ts index cbd1ba3eab53e7..64db0c18fb12ee 100644 --- a/x-pack/legacy/plugins/alerting/server/alerts_client.ts +++ b/x-pack/legacy/plugins/alerting/server/alerts_client.ts @@ -9,6 +9,16 @@ import { SavedObjectsClientContract, SavedObjectReference } from 'src/core/serve import { Alert, RawAlert, AlertTypeRegistry, AlertAction, Log } from './types'; import { TaskManager } from '../../task_manager'; import { validateAlertTypeParams } from './lib'; +import { CreateAPIKeyResult as SecurityPluginCreateAPIKeyResult } from '../../../../plugins/security/server'; + +interface FailedCreateAPIKeyResult { + created: false; +} +interface SuccessCreateAPIKeyResult { + created: true; + result: SecurityPluginCreateAPIKeyResult; +} +export type CreateAPIKeyResult = FailedCreateAPIKeyResult | SuccessCreateAPIKeyResult; interface ConstructorOptions { log: Log; @@ -16,6 +26,8 @@ interface ConstructorOptions { savedObjectsClient: SavedObjectsClientContract; alertTypeRegistry: AlertTypeRegistry; spaceId?: string; + getUserName: () => Promise; + createAPIKey: () => Promise; } interface FindOptions { @@ -42,7 +54,7 @@ interface FindResult { } interface CreateOptions { - data: Alert; + data: Pick>; options?: { migrationVersion?: Record; }; @@ -60,10 +72,12 @@ interface UpdateOptions { export class AlertsClient { private readonly log: Log; + private readonly getUserName: () => Promise; private readonly spaceId?: string; private readonly taskManager: TaskManager; private readonly savedObjectsClient: SavedObjectsClientContract; private readonly alertTypeRegistry: AlertTypeRegistry; + private readonly createAPIKey: () => Promise; constructor({ alertTypeRegistry, @@ -71,20 +85,31 @@ export class AlertsClient { taskManager, log, spaceId, + getUserName, + createAPIKey, }: ConstructorOptions) { this.log = log; + this.getUserName = getUserName; this.spaceId = spaceId; this.taskManager = taskManager; this.alertTypeRegistry = alertTypeRegistry; this.savedObjectsClient = savedObjectsClient; + this.createAPIKey = createAPIKey; } public async create({ data, options }: CreateOptions) { // Throws an error if alert type isn't registered const alertType = this.alertTypeRegistry.get(data.alertTypeId); const validatedAlertTypeParams = validateAlertTypeParams(alertType, data.alertTypeParams); + const apiKey = await this.createAPIKey(); + const username = await this.getUserName(); const { alert: rawAlert, references } = this.getRawAlert({ ...data, + createdBy: username, + updatedBy: username, + apiKey: apiKey.created + ? Buffer.from(`${apiKey.result.id}:${apiKey.result.api_key}`).toString('base64') + : undefined, alertTypeParams: validatedAlertTypeParams, }); const createdAlert = await this.savedObjectsClient.create('alert', rawAlert, { @@ -161,6 +186,7 @@ export class AlertsClient { const existingObject = await this.savedObjectsClient.get('alert', id); const { alertTypeId } = existingObject.attributes; const alertType = this.alertTypeRegistry.get(alertTypeId); + const apiKey = await this.createAPIKey(); // Validate const validatedAlertTypeParams = validateAlertTypeParams(alertType, data.alertTypeParams); @@ -173,6 +199,10 @@ export class AlertsClient { ...data, alertTypeParams: validatedAlertTypeParams, actions, + apiKey: apiKey.created + ? Buffer.from(`${apiKey.result.id}:${apiKey.result.api_key}`).toString('base64') + : null, + updatedBy: await this.getUserName(), }, { ...options, @@ -185,6 +215,7 @@ export class AlertsClient { public async enable({ id }: { id: string }) { const existingObject = await this.savedObjectsClient.get('alert', id); if (existingObject.attributes.enabled === false) { + const apiKey = await this.createAPIKey(); const scheduledTask = await this.scheduleAlert( id, existingObject.attributes.alertTypeId, @@ -195,7 +226,11 @@ export class AlertsClient { id, { enabled: true, + updatedBy: await this.getUserName(), scheduledTaskId: scheduledTask.id, + apiKey: apiKey.created + ? Buffer.from(`${apiKey.result.id}:${apiKey.result.api_key}`).toString('base64') + : null, }, { references: existingObject.references } ); @@ -205,16 +240,18 @@ export class AlertsClient { public async disable({ id }: { id: string }) { const existingObject = await this.savedObjectsClient.get('alert', id); if (existingObject.attributes.enabled === true) { - await this.taskManager.remove(existingObject.attributes.scheduledTaskId); await this.savedObjectsClient.update( 'alert', id, { enabled: false, scheduledTaskId: null, + apiKey: null, + updatedBy: await this.getUserName(), }, { references: existingObject.references } ); + await this.taskManager.remove(existingObject.attributes.scheduledTaskId); } } diff --git a/x-pack/legacy/plugins/alerting/server/init.ts b/x-pack/legacy/plugins/alerting/server/init.ts index 18efb9171883b5..4c60ff6200ae0b 100644 --- a/x-pack/legacy/plugins/alerting/server/init.ts +++ b/x-pack/legacy/plugins/alerting/server/init.ts @@ -4,7 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ +import Hapi from 'hapi'; +import uuid from 'uuid'; import { Legacy } from 'kibana'; +import KbnServer from 'src/legacy/server/kbn_server'; +import { ActionsPlugin } from '../../actions'; +import { TaskManager } from '../../task_manager'; +import { AlertingPlugin, Services } from './types'; +import { AlertTypeRegistry } from './alert_type_registry'; +import { AlertsClient, CreateAPIKeyResult } from './alerts_client'; +import { SpacesPlugin } from '../../spaces'; +import { KibanaRequest } from '../../../../../src/core/server'; +import { EncryptedSavedObjectsPlugin } from '../../encrypted_saved_objects'; +import { PluginSetupContract as SecurityPluginSetupContract } from '../../../../plugins/security/server'; +import { createOptionalPlugin } from '../../../server/lib/optional_plugin'; import { createAlertRoute, deleteAlertRoute, @@ -15,52 +28,92 @@ import { enableAlertRoute, disableAlertRoute, } from './routes'; -import { AlertingPlugin, Services } from './types'; -import { AlertTypeRegistry } from './alert_type_registry'; -import { AlertsClient } from './alerts_client'; -import { SpacesPlugin } from '../../spaces'; -import { createOptionalPlugin } from '../../../server/lib/optional_plugin'; -export function init(server: Legacy.Server) { +// Extend PluginProperties to indicate which plugins are guaranteed to exist +// due to being marked as dependencies +interface Plugins extends Hapi.PluginProperties { + actions: ActionsPlugin; + task_manager: TaskManager; + encrypted_saved_objects: EncryptedSavedObjectsPlugin; +} + +interface Server extends Legacy.Server { + plugins: Plugins; +} + +export function init(server: Server) { const config = server.config(); + const kbnServer = (server as unknown) as KbnServer; + const taskManager = server.plugins.task_manager; + const { callWithRequest } = server.plugins.elasticsearch.getCluster('admin'); const spaces = createOptionalPlugin( config, 'xpack.spaces', server.plugins, 'spaces' ); - - const { callWithInternalUser } = server.plugins.elasticsearch.getCluster('admin'); - const savedObjectsRepositoryWithInternalUser = server.savedObjects.getSavedObjectsRepository( - callWithInternalUser + const security = createOptionalPlugin( + config, + 'xpack.security', + kbnServer.newPlatform.setup.plugins, + 'security' ); - function getServices(basePath: string): Services { - const fakeRequest: any = { - headers: {}, - getBasePath: () => basePath, - }; + server.plugins.xpack_main.registerFeature({ + id: 'alerting', + name: 'Alerting', + app: ['alerting', 'kibana'], + privileges: { + all: { + savedObject: { + all: ['alert'], + read: [], + }, + ui: [], + api: ['alerting-read', 'alerting-all'], + }, + read: { + savedObject: { + all: [], + read: ['alert'], + }, + ui: [], + api: ['alerting-read'], + }, + }, + }); + + // Encrypted attributes + server.plugins.encrypted_saved_objects.registerType({ + type: 'alert', + attributesToEncrypt: new Set(['apiKey']), + attributesToExcludeFromAAD: new Set(['scheduledTaskId']), + }); + + function getServices(request: any): Services { return { - log: server.log.bind(server), - callCluster: callWithInternalUser, - savedObjectsClient: server.savedObjects.getScopedSavedObjectsClient(fakeRequest), + log: (...args) => server.log(...args), + callCluster: (...args) => callWithRequest(request, ...args), + savedObjectsClient: server.savedObjects.getScopedSavedObjectsClient(request), }; } + function getBasePath(spaceId?: string): string { + return spaces.isEnabled && spaceId + ? spaces.getBasePath(spaceId) + : ((server.config().get('server.basePath') || '') as string); + } + function spaceIdToNamespace(spaceId?: string): string | undefined { + return spaces.isEnabled && spaceId ? spaces.spaceIdToNamespace(spaceId) : undefined; + } - const taskManager = server.plugins.task_manager!; const alertTypeRegistry = new AlertTypeRegistry({ getServices, - taskManager: taskManager!, - executeAction: server.plugins.actions!.execute, - internalSavedObjectsRepository: savedObjectsRepositoryWithInternalUser, - getBasePath(...args) { - return spaces.isEnabled - ? spaces.getBasePath(...args) - : server.config().get('server.basePath'); - }, - spaceIdToNamespace(...args) { - return spaces.isEnabled ? spaces.spaceIdToNamespace(...args) : undefined; - }, + isSecurityEnabled: security.isEnabled, + taskManager, + executeAction: server.plugins.actions.execute, + encryptedSavedObjectsPlugin: server.plugins.encrypted_saved_objects, + getBasePath, + spaceIdToNamespace, }); // Register routes @@ -77,15 +130,37 @@ export function init(server: Legacy.Server) { server.decorate('request', 'getAlertsClient', function() { const request = this; const savedObjectsClient = request.getSavedObjectsClient(); + const alertsClient = new AlertsClient({ log: server.log.bind(server), savedObjectsClient, alertTypeRegistry, - taskManager: taskManager!, + taskManager, spaceId: spaces.isEnabled ? spaces.getSpaceId(request) : undefined, + async getUserName(): Promise { + if (!security.isEnabled) { + return null; + } + const user = await security.authc.getCurrentUser(KibanaRequest.from(request)); + return user ? user.username : null; + }, + async createAPIKey(): Promise { + if (!security.isEnabled) { + return { created: false }; + } + return { + created: true, + result: (await security.authc.createAPIKey(KibanaRequest.from(request), { + name: `source: alerting, generated uuid: "${uuid.v4()}"`, + role_descriptors: {}, + }))!, + }; + }, }); + return alertsClient; }); + const exposedFunctions: AlertingPlugin = { registerType: alertTypeRegistry.register.bind(alertTypeRegistry), listTypes: alertTypeRegistry.list.bind(alertTypeRegistry), diff --git a/x-pack/legacy/plugins/alerting/server/lib/create_fire_handler.test.ts b/x-pack/legacy/plugins/alerting/server/lib/create_fire_handler.test.ts index 30b342d9e5cb35..8d84c2515d067e 100644 --- a/x-pack/legacy/plugins/alerting/server/lib/create_fire_handler.test.ts +++ b/x-pack/legacy/plugins/alerting/server/lib/create_fire_handler.test.ts @@ -9,37 +9,20 @@ import { createFireHandler } from './create_fire_handler'; const createFireHandlerParams = { executeAction: jest.fn(), spaceId: 'default', + apiKey: 'MTIzOmFiYw==', spaceIdToNamespace: jest.fn().mockReturnValue(undefined), getBasePath: jest.fn().mockReturnValue(undefined), - alertSavedObject: { - id: '1', - type: 'alert', - attributes: { - alertTypeId: '123', - interval: '10s', - alertTypeParams: { - bar: true, + actions: [ + { + id: '1', + group: 'default', + params: { + foo: true, + contextVal: 'My {{context.value}} goes here', + stateVal: 'My {{state.value}} goes here', }, - actions: [ - { - group: 'default', - actionRef: 'action_0', - params: { - foo: true, - contextVal: 'My {{context.value}} goes here', - stateVal: 'My {{state.value}} goes here', - }, - }, - ], }, - references: [ - { - name: 'action_0', - type: 'action', - id: '1', - }, - ], - }, + ], }; beforeEach(() => jest.resetAllMocks()); @@ -51,6 +34,7 @@ test('calls executeAction per selected action', async () => { expect(createFireHandlerParams.executeAction.mock.calls[0]).toMatchInlineSnapshot(` Array [ Object { + "apiKey": "MTIzOmFiYw==", "id": "1", "params": Object { "contextVal": "My goes here", @@ -76,6 +60,7 @@ test('context attribute gets parameterized', async () => { expect(createFireHandlerParams.executeAction.mock.calls[0]).toMatchInlineSnapshot(` Array [ Object { + "apiKey": "MTIzOmFiYw==", "id": "1", "params": Object { "contextVal": "My context-val goes here", @@ -95,6 +80,7 @@ test('state attribute gets parameterized', async () => { expect(createFireHandlerParams.executeAction.mock.calls[0]).toMatchInlineSnapshot(` Array [ Object { + "apiKey": "MTIzOmFiYw==", "id": "1", "params": Object { "contextVal": "My goes here", @@ -106,39 +92,3 @@ test('state attribute gets parameterized', async () => { ] `); }); - -test('throws error if reference not found', async () => { - const params = { - spaceId: 'default', - executeAction: jest.fn(), - alertSavedObject: { - id: '1', - type: 'alert', - attributes: { - alertTypeId: '123', - interval: '10s', - alertTypeParams: { - bar: true, - }, - actions: [ - { - group: 'default', - actionRef: 'action_0', - params: { - foo: true, - contextVal: 'My {{context.value}} goes here', - stateVal: 'My {{state.value}} goes here', - }, - }, - ], - }, - references: [], - }, - }; - const fireHandler = createFireHandler(params); - await expect( - fireHandler('default', {}, { value: 'state-val' }) - ).rejects.toThrowErrorMatchingInlineSnapshot( - `"Action reference \\"action_0\\" not found in alert id: 1"` - ); -}); diff --git a/x-pack/legacy/plugins/alerting/server/lib/create_fire_handler.ts b/x-pack/legacy/plugins/alerting/server/lib/create_fire_handler.ts index b14510343c88f9..b847f85e5fc134 100644 --- a/x-pack/legacy/plugins/alerting/server/lib/create_fire_handler.ts +++ b/x-pack/legacy/plugins/alerting/server/lib/create_fire_handler.ts @@ -4,38 +4,29 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SavedObject } from 'src/core/server'; -import { RawAlertAction, State, Context } from '../types'; +import { AlertAction, State, Context } from '../types'; import { ActionsPlugin } from '../../../actions'; import { transformActionParams } from './transform_action_params'; interface CreateFireHandlerOptions { executeAction: ActionsPlugin['execute']; - alertSavedObject: SavedObject; + actions: AlertAction[]; spaceId: string; + apiKey?: string; } export function createFireHandler({ executeAction, - alertSavedObject, + actions: alertActions, spaceId, + apiKey, }: CreateFireHandlerOptions) { return async (actionGroup: string, context: Context, state: State) => { - const alertActions: RawAlertAction[] = alertSavedObject.attributes.actions; const actions = alertActions .filter(({ group }) => group === actionGroup) .map(action => { - const actionReference = alertSavedObject.references.find( - obj => obj.name === action.actionRef - ); - if (!actionReference) { - throw new Error( - `Action reference "${action.actionRef}" not found in alert id: ${alertSavedObject.id}` - ); - } return { ...action, - id: actionReference.id, params: transformActionParams(action.params, state, context), }; }); @@ -44,6 +35,7 @@ export function createFireHandler({ id: action.id, params: action.params, spaceId, + apiKey, }); } }; diff --git a/x-pack/legacy/plugins/alerting/server/lib/get_create_task_runner_function.test.ts b/x-pack/legacy/plugins/alerting/server/lib/get_create_task_runner_function.test.ts index 7d2387ec56875a..444e9725f62e5e 100644 --- a/x-pack/legacy/plugins/alerting/server/lib/get_create_task_runner_function.test.ts +++ b/x-pack/legacy/plugins/alerting/server/lib/get_create_task_runner_function.test.ts @@ -10,6 +10,7 @@ import { AlertExecutorOptions } from '../types'; import { ConcreteTaskInstance } from '../../../task_manager'; import { SavedObjectsClientMock } from '../../../../../../src/core/server/mocks'; import { getCreateTaskRunnerFunction } from './get_create_task_runner_function'; +import { encryptedSavedObjectsMock } from '../../../encrypted_saved_objects/server/plugin.mock'; let fakeTimer: sinon.SinonFakeTimers; let mockedTaskInstance: ConcreteTaskInstance; @@ -38,22 +39,23 @@ beforeAll(() => { afterAll(() => fakeTimer.restore()); const savedObjectsClient = SavedObjectsClientMock.create(); +const encryptedSavedObjectsPlugin = encryptedSavedObjectsMock.create(); +const services = { + log: jest.fn(), + callCluster: jest.fn(), + savedObjectsClient, +}; const getCreateTaskRunnerFunctionParams = { - getServices() { - return { - log: jest.fn(), - callCluster: jest.fn(), - savedObjectsClient: SavedObjectsClientMock.create(), - }; - }, + isSecurityEnabled: true, + getServices: jest.fn().mockReturnValue(services), alertType: { id: 'test', name: 'My test alert', executor: jest.fn(), }, executeAction: jest.fn(), - internalSavedObjectsRepository: savedObjectsClient, + encryptedSavedObjectsPlugin, spaceIdToNamespace: jest.fn().mockReturnValue(undefined), getBasePath: jest.fn().mockReturnValue(undefined), }; @@ -87,30 +89,41 @@ const mockedAlertTypeSavedObject = { ], }; -beforeEach(() => jest.resetAllMocks()); +beforeEach(() => { + jest.resetAllMocks(); + getCreateTaskRunnerFunctionParams.getServices.mockReturnValue(services); +}); test('successfully executes the task', async () => { const createTaskRunner = getCreateTaskRunnerFunction(getCreateTaskRunnerFunctionParams); savedObjectsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); + encryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + apiKey: Buffer.from('123:abc').toString('base64'), + }, + references: [], + }); const runner = createTaskRunner({ taskInstance: mockedTaskInstance }); const runnerResult = await runner.run(); expect(runnerResult).toMatchInlineSnapshot(` - Object { - "runAt": 1970-01-01T00:00:10.000Z, - "state": Object { - "alertInstances": Object {}, - "alertTypeState": undefined, - "previousStartedAt": 1970-01-01T00:00:00.000Z, - }, - } - `); + Object { + "runAt": 1970-01-01T00:00:10.000Z, + "state": Object { + "alertInstances": Object {}, + "alertTypeState": undefined, + "previousStartedAt": 1970-01-01T00:00:00.000Z, + }, + } + `); expect(getCreateTaskRunnerFunctionParams.alertType.executor).toHaveBeenCalledTimes(1); const call = getCreateTaskRunnerFunctionParams.alertType.executor.mock.calls[0][0]; expect(call.params).toMatchInlineSnapshot(` - Object { - "bar": true, - } - `); + Object { + "bar": true, + } + `); expect(call.startedAt).toMatchInlineSnapshot(`1970-01-01T00:00:00.000Z`); expect(call.state).toMatchInlineSnapshot(`Object {}`); expect(call.services.alertInstanceFactory).toBeTruthy(); @@ -120,18 +133,27 @@ test('successfully executes the task', async () => { test('fireAction is called per alert instance that fired', async () => { getCreateTaskRunnerFunctionParams.alertType.executor.mockImplementation( - ({ services }: AlertExecutorOptions) => { - services.alertInstanceFactory('1').fire('default'); + ({ services: executorServices }: AlertExecutorOptions) => { + executorServices.alertInstanceFactory('1').fire('default'); } ); const createTaskRunner = getCreateTaskRunnerFunction(getCreateTaskRunnerFunctionParams); savedObjectsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); + encryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + apiKey: Buffer.from('123:abc').toString('base64'), + }, + references: [], + }); const runner = createTaskRunner({ taskInstance: mockedTaskInstance }); await runner.run(); expect(getCreateTaskRunnerFunctionParams.executeAction).toHaveBeenCalledTimes(1); expect(getCreateTaskRunnerFunctionParams.executeAction.mock.calls[0]).toMatchInlineSnapshot(` Array [ Object { + "apiKey": "MTIzOmFiYw==", "id": "1", "params": Object { "foo": true, @@ -144,12 +166,20 @@ test('fireAction is called per alert instance that fired', async () => { test('persists alertInstances passed in from state, only if they fire', async () => { getCreateTaskRunnerFunctionParams.alertType.executor.mockImplementation( - ({ services }: AlertExecutorOptions) => { - services.alertInstanceFactory('1').fire('default'); + ({ services: executorServices }: AlertExecutorOptions) => { + executorServices.alertInstanceFactory('1').fire('default'); } ); const createTaskRunner = getCreateTaskRunnerFunction(getCreateTaskRunnerFunctionParams); savedObjectsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); + encryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + apiKey: Buffer.from('123:abc').toString('base64'), + }, + references: [], + }); const runner = createTaskRunner({ taskInstance: { ...mockedTaskInstance, @@ -164,17 +194,17 @@ test('persists alertInstances passed in from state, only if they fire', async () }); const runnerResult = await runner.run(); expect(runnerResult.state.alertInstances).toMatchInlineSnapshot(` - Object { - "1": Object { - "meta": Object { - "lastFired": 0, - }, - "state": Object { - "bar": false, - }, - }, - } - `); + Object { + "1": Object { + "meta": Object { + "lastFired": 0, + }, + "state": Object { + "bar": false, + }, + }, + } + `); }); test('validates params before executing the alert type', async () => { @@ -190,8 +220,81 @@ test('validates params before executing the alert type', async () => { }, }); savedObjectsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); + encryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + apiKey: Buffer.from('123:abc').toString('base64'), + }, + references: [], + }); const runner = createTaskRunner({ taskInstance: mockedTaskInstance }); await expect(runner.run()).rejects.toThrowErrorMatchingInlineSnapshot( `"alertTypeParams invalid: [param1]: expected value of type [string] but got [undefined]"` ); }); + +test('throws error if reference not found', async () => { + const createTaskRunner = getCreateTaskRunnerFunction(getCreateTaskRunnerFunctionParams); + savedObjectsClient.get.mockResolvedValueOnce({ + ...mockedAlertTypeSavedObject, + references: [], + }); + encryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + apiKey: Buffer.from('123:abc').toString('base64'), + }, + references: [], + }); + const runner = createTaskRunner({ taskInstance: mockedTaskInstance }); + await expect(runner.run()).rejects.toThrowErrorMatchingInlineSnapshot( + `"Action reference \\"action_0\\" not found in alert id: 1"` + ); +}); + +test('uses API key when provided', async () => { + const createTaskRunner = getCreateTaskRunnerFunction(getCreateTaskRunnerFunctionParams); + savedObjectsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); + encryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + apiKey: Buffer.from('123:abc').toString('base64'), + }, + references: [], + }); + const runner = createTaskRunner({ taskInstance: mockedTaskInstance }); + + await runner.run(); + expect(getCreateTaskRunnerFunctionParams.getServices).toHaveBeenCalledWith({ + getBasePath: expect.anything(), + headers: { + // base64 encoded "123:abc" + authorization: 'ApiKey MTIzOmFiYw==', + }, + }); +}); + +test(`doesn't use API key when not provided`, async () => { + const createTaskRunner = getCreateTaskRunnerFunction({ + ...getCreateTaskRunnerFunctionParams, + isSecurityEnabled: false, + }); + savedObjectsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); + encryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: {}, + references: [], + }); + const runner = createTaskRunner({ taskInstance: mockedTaskInstance }); + + await runner.run(); + + expect(getCreateTaskRunnerFunctionParams.getServices).toHaveBeenCalledWith({ + getBasePath: expect.anything(), + headers: {}, + }); +}); diff --git a/x-pack/legacy/plugins/alerting/server/lib/get_create_task_runner_function.ts b/x-pack/legacy/plugins/alerting/server/lib/get_create_task_runner_function.ts index 996271a26414ef..314b2c04ba442d 100644 --- a/x-pack/legacy/plugins/alerting/server/lib/get_create_task_runner_function.ts +++ b/x-pack/legacy/plugins/alerting/server/lib/get_create_task_runner_function.ts @@ -4,24 +4,31 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SavedObjectsClientContract } from 'src/core/server'; import { ActionsPlugin } from '../../../actions'; -import { AlertType, Services, AlertServices } from '../types'; import { ConcreteTaskInstance } from '../../../task_manager'; import { createFireHandler } from './create_fire_handler'; import { createAlertInstanceFactory } from './create_alert_instance_factory'; import { AlertInstance } from './alert_instance'; import { getNextRunAt } from './get_next_run_at'; import { validateAlertTypeParams } from './validate_alert_type_params'; -import { SpacesPlugin } from '../../../spaces'; +import { EncryptedSavedObjectsPlugin } from '../../../encrypted_saved_objects'; +import { + AlertType, + AlertServices, + GetBasePathFunction, + GetServicesFunction, + RawAlert, + SpaceIdToNamespaceFunction, +} from '../types'; interface CreateTaskRunnerFunctionOptions { - getServices: (basePath: string) => Services; + isSecurityEnabled: boolean; + getServices: GetServicesFunction; alertType: AlertType; executeAction: ActionsPlugin['execute']; - internalSavedObjectsRepository: SavedObjectsClientContract; - spaceIdToNamespace: SpacesPlugin['spaceIdToNamespace']; - getBasePath: SpacesPlugin['getBasePath']; + encryptedSavedObjectsPlugin: EncryptedSavedObjectsPlugin; + spaceIdToNamespace: SpaceIdToNamespaceFunction; + getBasePath: GetBasePathFunction; } interface TaskRunnerOptions { @@ -32,30 +39,67 @@ export function getCreateTaskRunnerFunction({ getServices, alertType, executeAction, - internalSavedObjectsRepository, + encryptedSavedObjectsPlugin, spaceIdToNamespace, getBasePath, + isSecurityEnabled, }: CreateTaskRunnerFunctionOptions) { return ({ taskInstance }: TaskRunnerOptions) => { return { run: async () => { - const namespace = spaceIdToNamespace(taskInstance.params.spaceId); - const alertSavedObject = await internalSavedObjectsRepository.get( + const { alertId, spaceId } = taskInstance.params; + const requestHeaders: Record = {}; + const namespace = spaceIdToNamespace(spaceId); + // Only fetch encrypted attributes here, we'll create a saved objects client + // scoped with the API key to fetch the remaining data. + const { + attributes: { apiKey }, + } = await encryptedSavedObjectsPlugin.getDecryptedAsInternalUser( 'alert', - taskInstance.params.alertId, + alertId, { namespace } ); + if (isSecurityEnabled && !apiKey) { + throw new Error('API key is required. The attribute "apiKey" is missing.'); + } else if (isSecurityEnabled) { + requestHeaders.authorization = `ApiKey ${apiKey}`; + } + + const fakeRequest = { + headers: requestHeaders, + getBasePath: () => getBasePath(spaceId), + }; + + const services = getServices(fakeRequest); + // Ensure API key is still valid and user has access + const { + attributes: { alertTypeParams, actions, interval }, + references, + } = await services.savedObjectsClient.get('alert', alertId); + // Validate - const validatedAlertTypeParams = validateAlertTypeParams( - alertType, - alertSavedObject.attributes.alertTypeParams - ); + const validatedAlertTypeParams = validateAlertTypeParams(alertType, alertTypeParams); + + // Inject ids into actions + const actionsWithIds = actions.map(action => { + const actionReference = references.find(obj => obj.name === action.actionRef); + if (!actionReference) { + throw new Error( + `Action reference "${action.actionRef}" not found in alert id: ${alertId}` + ); + } + return { + ...action, + id: actionReference.id, + }; + }); const fireHandler = createFireHandler({ - alertSavedObject, executeAction, - spaceId: taskInstance.params.spaceId, + apiKey, + actions: actionsWithIds, + spaceId, }); const alertInstances: Record = {}; const alertInstancesData = taskInstance.state.alertInstances || {}; @@ -65,7 +109,7 @@ export function getCreateTaskRunnerFunction({ const alertInstanceFactory = createAlertInstanceFactory(alertInstances); const alertTypeServices: AlertServices = { - ...getServices(taskInstance.params.basePath), + ...services, alertInstanceFactory, }; @@ -94,10 +138,7 @@ export function getCreateTaskRunnerFunction({ }) ); - const nextRunAt = getNextRunAt( - new Date(taskInstance.startedAt!), - alertSavedObject.attributes.interval - ); + const nextRunAt = getNextRunAt(new Date(taskInstance.startedAt!), interval); return { state: { diff --git a/x-pack/legacy/plugins/alerting/server/routes/create.ts b/x-pack/legacy/plugins/alerting/server/routes/create.ts index 9d0fc5598b648d..205fa34f66b218 100644 --- a/x-pack/legacy/plugins/alerting/server/routes/create.ts +++ b/x-pack/legacy/plugins/alerting/server/routes/create.ts @@ -24,6 +24,7 @@ export function createAlertRoute(server: Hapi.Server) { method: 'POST', path: '/api/alert', options: { + tags: ['access:alerting-all'], validate: { options: { abortEarly: false, diff --git a/x-pack/legacy/plugins/alerting/server/routes/delete.ts b/x-pack/legacy/plugins/alerting/server/routes/delete.ts index 7dbb336b3cc1d3..428c6d69b77d40 100644 --- a/x-pack/legacy/plugins/alerting/server/routes/delete.ts +++ b/x-pack/legacy/plugins/alerting/server/routes/delete.ts @@ -18,6 +18,7 @@ export function deleteAlertRoute(server: Hapi.Server) { method: 'DELETE', path: '/api/alert/{id}', options: { + tags: ['access:alerting-all'], validate: { params: Joi.object() .keys({ diff --git a/x-pack/legacy/plugins/alerting/server/routes/disable.ts b/x-pack/legacy/plugins/alerting/server/routes/disable.ts index 19cd16f7981ff5..a80f5f21b16e3c 100644 --- a/x-pack/legacy/plugins/alerting/server/routes/disable.ts +++ b/x-pack/legacy/plugins/alerting/server/routes/disable.ts @@ -11,6 +11,7 @@ export function disableAlertRoute(server: Hapi.Server) { method: 'POST', path: '/api/alert/{id}/_disable', options: { + tags: ['access:alerting-all'], response: { emptyStatusCode: 204, }, diff --git a/x-pack/legacy/plugins/alerting/server/routes/enable.ts b/x-pack/legacy/plugins/alerting/server/routes/enable.ts index b11f5ec9fd200c..d8943b8a85d722 100644 --- a/x-pack/legacy/plugins/alerting/server/routes/enable.ts +++ b/x-pack/legacy/plugins/alerting/server/routes/enable.ts @@ -11,6 +11,7 @@ export function enableAlertRoute(server: Hapi.Server) { method: 'POST', path: '/api/alert/{id}/_enable', options: { + tags: ['access:alerting-all'], response: { emptyStatusCode: 204, }, diff --git a/x-pack/legacy/plugins/alerting/server/routes/find.ts b/x-pack/legacy/plugins/alerting/server/routes/find.ts index e8f691c89f8fb3..6085c5d714ea58 100644 --- a/x-pack/legacy/plugins/alerting/server/routes/find.ts +++ b/x-pack/legacy/plugins/alerting/server/routes/find.ts @@ -30,6 +30,7 @@ export function findRoute(server: Hapi.Server) { method: 'GET', path: '/api/alert/_find', options: { + tags: ['access:alerting-read'], validate: { query: Joi.object() .keys({ diff --git a/x-pack/legacy/plugins/alerting/server/routes/get.ts b/x-pack/legacy/plugins/alerting/server/routes/get.ts index 87cdec17db99ce..ce172176aedbf2 100644 --- a/x-pack/legacy/plugins/alerting/server/routes/get.ts +++ b/x-pack/legacy/plugins/alerting/server/routes/get.ts @@ -18,6 +18,7 @@ export function getRoute(server: Hapi.Server) { method: 'GET', path: `/api/alert/{id}`, options: { + tags: ['access:alerting-read'], validate: { params: Joi.object() .keys({ diff --git a/x-pack/legacy/plugins/alerting/server/routes/list_alert_types.ts b/x-pack/legacy/plugins/alerting/server/routes/list_alert_types.ts index 1c342e8cd6a25c..8a1e507fd1e055 100644 --- a/x-pack/legacy/plugins/alerting/server/routes/list_alert_types.ts +++ b/x-pack/legacy/plugins/alerting/server/routes/list_alert_types.ts @@ -10,6 +10,9 @@ export function listAlertTypesRoute(server: Hapi.Server) { server.route({ method: 'GET', path: `/api/alert/types`, + options: { + tags: ['access:alerting-read'], + }, async handler(request: Hapi.Request) { return request.server.plugins.alerting!.listTypes(); }, diff --git a/x-pack/legacy/plugins/alerting/server/routes/update.ts b/x-pack/legacy/plugins/alerting/server/routes/update.ts index e2192970d34e0c..90629d5fdd35f6 100644 --- a/x-pack/legacy/plugins/alerting/server/routes/update.ts +++ b/x-pack/legacy/plugins/alerting/server/routes/update.ts @@ -26,6 +26,7 @@ export function updateAlertRoute(server: Hapi.Server) { method: 'PUT', path: '/api/alert/{id}', options: { + tags: ['access:alerting-all'], validate: { options: { abortEarly: false, diff --git a/x-pack/legacy/plugins/alerting/server/types.ts b/x-pack/legacy/plugins/alerting/server/types.ts index b1e268431e40e9..eb9ba56a10d2b3 100644 --- a/x-pack/legacy/plugins/alerting/server/types.ts +++ b/x-pack/legacy/plugins/alerting/server/types.ts @@ -11,6 +11,9 @@ import { AlertTypeRegistry } from './alert_type_registry'; export type State = Record; export type Context = Record; export type WithoutQueryAndParams = Pick>; +export type GetServicesFunction = (request: any) => Services; +export type GetBasePathFunction = (spaceId?: string) => string; +export type SpaceIdToNamespaceFunction = (spaceId?: string) => string | undefined; export type Log = ( tags: string | string[], @@ -66,6 +69,9 @@ export interface Alert { actions: AlertAction[]; alertTypeParams: Record; scheduledTaskId?: string; + createdBy: string | null; + updatedBy: string | null; + apiKey?: string; } export interface RawAlert extends SavedObjectAttributes { @@ -75,6 +81,9 @@ export interface RawAlert extends SavedObjectAttributes { actions: RawAlertAction[]; alertTypeParams: SavedObjectAttributes; scheduledTaskId?: string; + createdBy: string | null; + updatedBy: string | null; + apiKey?: string; } export interface AlertingPlugin { diff --git a/x-pack/plugins/security/server/authentication/index.ts b/x-pack/plugins/security/server/authentication/index.ts index c07a6a84c07faa..7b184e298b7f48 100644 --- a/x-pack/plugins/security/server/authentication/index.ts +++ b/x-pack/plugins/security/server/authentication/index.ts @@ -21,6 +21,7 @@ export { Authenticator, ProviderLoginAttempt } from './authenticator'; export { AuthenticationResult } from './authentication_result'; export { DeauthenticationResult } from './deauthentication_result'; export { OIDCAuthenticationFlow } from './providers'; +export { CreateAPIKeyResult } from './api_keys'; interface SetupAuthenticationParams { core: CoreSetup; diff --git a/x-pack/plugins/security/server/index.ts b/x-pack/plugins/security/server/index.ts index fe1ccf4ab7fa95..ec43bbd95901aa 100644 --- a/x-pack/plugins/security/server/index.ts +++ b/x-pack/plugins/security/server/index.ts @@ -18,6 +18,7 @@ export { AuthenticationResult, DeauthenticationResult, OIDCAuthenticationFlow, + CreateAPIKeyResult, } from './authentication'; export { PluginSetupContract } from './plugin'; diff --git a/x-pack/scripts/functional_tests.js b/x-pack/scripts/functional_tests.js index 52ccea2459e9cb..002701269e3ede 100644 --- a/x-pack/scripts/functional_tests.js +++ b/x-pack/scripts/functional_tests.js @@ -12,7 +12,7 @@ require('@kbn/test').runTestsCli([ require.resolve('../test/functional/config.js'), require.resolve('../test/api_integration/config_security_basic.js'), require.resolve('../test/api_integration/config.js'), - require.resolve('../test/alerting_api_integration/config_security_enabled.js'), + require.resolve('../test/alerting_api_integration/security_and_spaces/config.ts'), require.resolve('../test/plugin_api_integration/config.js'), require.resolve('../test/kerberos_api_integration/config'), require.resolve('../test/kerberos_api_integration/anonymous_access.config'), diff --git a/x-pack/test/alerting_api_integration/apis/actions/create.ts b/x-pack/test/alerting_api_integration/apis/actions/create.ts deleted file mode 100644 index 670ccdc310920e..00000000000000 --- a/x-pack/test/alerting_api_integration/apis/actions/create.ts +++ /dev/null @@ -1,134 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../ftr_provider_context'; - -export default function createActionTests({ getService }: FtrProviderContext) { - const supertest = getService('supertest'); - const esArchiver = getService('esArchiver'); - - describe('create', () => { - after(() => esArchiver.unload('empty_kibana')); - - it('should return 200 when creating an action and not return encrypted attributes', async () => { - await supertest - .post('/api/action') - .set('kbn-xsrf', 'foo') - .send({ - description: 'My action', - actionTypeId: 'test.index-record', - config: { - unencrypted: `This value shouldn't get encrypted`, - }, - secrets: { - encrypted: 'This value should be encrypted', - }, - }) - .expect(200) - .then((resp: any) => { - expect(resp.body).to.eql({ - id: resp.body.id, - description: 'My action', - actionTypeId: 'test.index-record', - config: { - unencrypted: `This value shouldn't get encrypted`, - }, - }); - expect(typeof resp.body.id).to.be('string'); - }); - }); - - it('should return 200 when creating an action inside a space and to not be accessible from another space', async () => { - const { body: createdAction } = await supertest - .post('/s/space_1/api/action') - .set('kbn-xsrf', 'foo') - .send({ - description: 'My action', - actionTypeId: 'test.index-record', - config: { - unencrypted: `This value shouldn't get encrypted`, - }, - secrets: { - encrypted: 'This value should be encrypted', - }, - }) - .expect(200); - expect(createdAction).to.eql({ - id: createdAction.id, - description: 'My action', - actionTypeId: 'test.index-record', - config: { - unencrypted: `This value shouldn't get encrypted`, - }, - }); - expect(typeof createdAction.id).to.be('string'); - await supertest.get(`/s/space_1/api/action/${createdAction.id}`).expect(200); - await supertest.get(`/api/action/${createdAction.id}`).expect(404); - }); - - it(`should return 400 when action type isn't registered`, async () => { - await supertest - .post('/api/action') - .set('kbn-xsrf', 'foo') - .send({ - description: 'My action', - actionTypeId: 'test.unregistered-action-type', - config: {}, - }) - .expect(400) - .then((resp: any) => { - expect(resp.body).to.eql({ - statusCode: 400, - error: 'Bad Request', - message: 'Action type "test.unregistered-action-type" is not registered.', - }); - }); - }); - - it('should return 400 when payload is empty and invalid', async () => { - await supertest - .post('/api/action') - .set('kbn-xsrf', 'foo') - .send({}) - .expect(400) - .then((resp: any) => { - expect(resp.body).to.eql({ - statusCode: 400, - error: 'Bad Request', - message: - 'child "description" fails because ["description" is required]. child "actionTypeId" fails because ["actionTypeId" is required]', - validation: { - source: 'payload', - keys: ['description', 'actionTypeId'], - }, - }); - }); - }); - - it(`should return 400 when config isn't valid`, async () => { - await supertest - .post('/api/action') - .set('kbn-xsrf', 'foo') - .send({ - description: 'my description', - actionTypeId: 'test.index-record', - config: { - unencrypted: 'my unencrypted text', - }, - }) - .expect(400) - .then((resp: any) => { - expect(resp.body).to.eql({ - statusCode: 400, - error: 'Bad Request', - message: - 'error validating action type secrets: [encrypted]: expected value of type [string] but got [undefined]', - }); - }); - }); - }); -} diff --git a/x-pack/test/alerting_api_integration/apis/actions/delete.ts b/x-pack/test/alerting_api_integration/apis/actions/delete.ts deleted file mode 100644 index 25d6c06ace2afc..00000000000000 --- a/x-pack/test/alerting_api_integration/apis/actions/delete.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import expect from '@kbn/expect'; - -import { ES_ARCHIVER_ACTION_ID, SPACE_1_ES_ARCHIVER_ACTION_ID } from './constants'; -import { FtrProviderContext } from '../../ftr_provider_context'; - -export default function deleteActionTests({ getService }: FtrProviderContext) { - const supertest = getService('supertest'); - const esArchiver = getService('esArchiver'); - - describe('delete', () => { - beforeEach(() => esArchiver.load('actions/basic')); - afterEach(() => esArchiver.unload('actions/basic')); - - it('should return 204 when deleting an action', async () => { - await supertest - .delete(`/api/action/${ES_ARCHIVER_ACTION_ID}`) - .set('kbn-xsrf', 'foo') - .expect(204, ''); - }); - - it('should return 204 when deleting an action in a space', async () => { - await supertest - .delete(`/s/space_1/api/action/${SPACE_1_ES_ARCHIVER_ACTION_ID}`) - .set('kbn-xsrf', 'foo') - .expect(204, ''); - }); - - it('should return 404 when deleting an action in another space', async () => { - await supertest - .delete(`/api/action/${SPACE_1_ES_ARCHIVER_ACTION_ID}`) - .set('kbn-xsrf', 'foo') - .expect(404); - }); - - it(`should return 404 when action doesn't exist`, async () => { - await supertest - .delete('/api/action/2') - .set('kbn-xsrf', 'foo') - .expect(404) - .then((resp: any) => { - expect(resp.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Saved object [action/2] not found', - }); - }); - }); - }); -} diff --git a/x-pack/test/alerting_api_integration/apis/actions/execute.ts b/x-pack/test/alerting_api_integration/apis/actions/execute.ts deleted file mode 100644 index 919c0183786909..00000000000000 --- a/x-pack/test/alerting_api_integration/apis/actions/execute.ts +++ /dev/null @@ -1,293 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import expect from '@kbn/expect'; -import { ES_ARCHIVER_ACTION_ID, SPACE_1_ES_ARCHIVER_ACTION_ID } from './constants'; -import { FtrProviderContext } from '../../ftr_provider_context'; - -export default function({ getService }: FtrProviderContext) { - const supertest = getService('supertest'); - const esArchiver = getService('esArchiver'); - const es = getService('es'); - const retry = getService('retry'); - - const esTestIndexName = '.kibaka-alerting-test-data'; - - describe('execute', () => { - beforeEach(() => esArchiver.load('actions/basic')); - afterEach(() => esArchiver.unload('actions/basic')); - - before(async () => { - await es.indices.delete({ index: esTestIndexName, ignore: [404] }); - await es.indices.create({ - index: esTestIndexName, - body: { - mappings: { - properties: { - source: { - type: 'keyword', - }, - reference: { - type: 'keyword', - }, - params: { - enabled: false, - type: 'object', - }, - config: { - enabled: false, - type: 'object', - }, - state: { - enabled: false, - type: 'object', - }, - }, - }, - }, - }); - }); - after(() => es.indices.delete({ index: esTestIndexName })); - - it('decrypts attributes when calling execute API', async () => { - await supertest - .post(`/api/action/${ES_ARCHIVER_ACTION_ID}/_execute`) - .set('kbn-xsrf', 'foo') - .send({ - params: { - index: esTestIndexName, - reference: 'actions-execute-1', - message: 'Testing 123', - }, - }) - .expect(200) - .then((resp: any) => { - expect(resp.body).to.be.an('object'); - }); - const indexedRecord = await retry.tryForTime(15000, async () => { - const searchResult = await es.search({ - index: esTestIndexName, - body: { - query: { - bool: { - must: [ - { - term: { - source: 'action:test.index-record', - }, - }, - { - term: { - reference: 'actions-execute-1', - }, - }, - ], - }, - }, - }, - }); - expect(searchResult.hits.total.value).to.eql(1); - return searchResult.hits.hits[0]; - }); - expect(indexedRecord._source).to.eql({ - params: { - index: esTestIndexName, - reference: 'actions-execute-1', - message: 'Testing 123', - }, - config: { - unencrypted: `This value shouldn't get encrypted`, - }, - secrets: { - encrypted: 'This value should be encrypted', - }, - reference: 'actions-execute-1', - source: 'action:test.index-record', - }); - }); - - it(`can't execute from another space`, async () => { - await supertest - .post(`/api/action/${SPACE_1_ES_ARCHIVER_ACTION_ID}/_execute`) - .set('kbn-xsrf', 'foo') - .send({ - params: { - index: esTestIndexName, - reference: 'actions-execute-2', - message: 'Testing 123', - }, - }) - .expect(404); - }); - - it('execute works in a space', async () => { - await supertest - .post(`/s/space_1/api/action/${SPACE_1_ES_ARCHIVER_ACTION_ID}/_execute`) - .set('kbn-xsrf', 'foo') - .send({ - params: { - index: esTestIndexName, - reference: 'actions-execute-3', - message: 'Testing 123', - }, - }) - .expect(200) - .then((resp: any) => { - expect(resp.body).to.be.an('object'); - }); - const indexedRecord = await retry.tryForTime(15000, async () => { - const searchResult = await es.search({ - index: esTestIndexName, - body: { - query: { - bool: { - must: [ - { - term: { - source: 'action:test.index-record', - }, - }, - { - term: { - reference: 'actions-execute-3', - }, - }, - ], - }, - }, - }, - }); - expect(searchResult.hits.total.value).to.eql(1); - return searchResult.hits.hits[0]; - }); - expect(indexedRecord._source).to.eql({ - params: { - index: esTestIndexName, - reference: 'actions-execute-3', - message: 'Testing 123', - }, - config: { - unencrypted: `This value shouldn't get encrypted`, - }, - secrets: { - encrypted: 'This value should be encrypted', - }, - reference: 'actions-execute-3', - source: 'action:test.index-record', - }); - }); - - it('execute still works with encrypted attributes after updating an action', async () => { - const { body: updatedAction } = await supertest - .put(`/api/action/${ES_ARCHIVER_ACTION_ID}`) - .set('kbn-xsrf', 'foo') - .send({ - description: 'My action updated', - config: { - unencrypted: `This value shouldn't get encrypted`, - }, - secrets: { - encrypted: 'This value should be encrypted', - }, - }) - .expect(200); - expect(updatedAction).to.eql({ - id: ES_ARCHIVER_ACTION_ID, - actionTypeId: 'test.index-record', - description: 'My action updated', - config: { - unencrypted: `This value shouldn't get encrypted`, - }, - }); - await supertest - .post(`/api/action/${ES_ARCHIVER_ACTION_ID}/_execute`) - .set('kbn-xsrf', 'foo') - .send({ - params: { - index: esTestIndexName, - reference: 'actions-execute-4', - message: 'Testing 123', - }, - }) - .expect(200) - .then((resp: any) => { - expect(resp.body).to.be.an('object'); - }); - const indexedRecord = await retry.tryForTime(15000, async () => { - const searchResult = await es.search({ - index: esTestIndexName, - body: { - query: { - bool: { - must: [ - { - term: { - source: 'action:test.index-record', - }, - }, - { - term: { - reference: 'actions-execute-4', - }, - }, - ], - }, - }, - }, - }); - expect(searchResult.hits.total.value).to.eql(1); - return searchResult.hits.hits[0]; - }); - expect(indexedRecord._source).to.eql({ - params: { - index: esTestIndexName, - reference: 'actions-execute-4', - message: 'Testing 123', - }, - config: { - unencrypted: `This value shouldn't get encrypted`, - }, - secrets: { - encrypted: 'This value should be encrypted', - }, - reference: 'actions-execute-4', - source: 'action:test.index-record', - }); - }); - - it(`should return 404 when action doesn't exist`, async () => { - const { body: response } = await supertest - .post('/api/action/1/_execute') - .set('kbn-xsrf', 'foo') - .send({ - params: { foo: true }, - }) - .expect(404); - expect(response).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Saved object [action/1] not found', - }); - }); - - it('should return 400 when payload is empty and invalid', async () => { - const { body: response } = await supertest - .post(`/api/action/${ES_ARCHIVER_ACTION_ID}/_execute`) - .set('kbn-xsrf', 'foo') - .send({}) - .expect(400); - expect(response).to.eql({ - statusCode: 400, - error: 'Bad Request', - message: 'child "params" fails because ["params" is required]', - validation: { - source: 'payload', - keys: ['params'], - }, - }); - }); - }); -} diff --git a/x-pack/test/alerting_api_integration/apis/actions/find.ts b/x-pack/test/alerting_api_integration/apis/actions/find.ts deleted file mode 100644 index dafb9d1eaeaf13..00000000000000 --- a/x-pack/test/alerting_api_integration/apis/actions/find.ts +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import expect from '@kbn/expect'; -import { ES_ARCHIVER_ACTION_ID, SPACE_1_ES_ARCHIVER_ACTION_ID } from './constants'; -import { FtrProviderContext } from '../../ftr_provider_context'; - -export default function findActionTests({ getService }: FtrProviderContext) { - const supertest = getService('supertest'); - const esArchiver = getService('esArchiver'); - - describe('find', () => { - before(() => esArchiver.load('actions/basic')); - after(() => esArchiver.unload('actions/basic')); - - it('should return 200 with individual responses', async () => { - await supertest - .get( - '/api/action/_find?search=test.index-record&search_fields=actionTypeId&fields=description' - ) - .expect(200) - .then((resp: any) => { - expect(resp.body).to.eql({ - page: 1, - perPage: 20, - total: 1, - data: [ - { - id: ES_ARCHIVER_ACTION_ID, - description: 'My action', - }, - ], - }); - }); - }); - - it('should return 200 with individual responses in a space', async () => { - await supertest - .get( - '/s/space_1/api/action/_find?search=test.index-record&search_fields=actionTypeId&fields=description' - ) - .expect(200) - .then((resp: any) => { - expect(resp.body).to.eql({ - page: 1, - perPage: 20, - total: 1, - data: [ - { - id: SPACE_1_ES_ARCHIVER_ACTION_ID, - description: 'My action', - }, - ], - }); - }); - }); - - it('should not return encrypted attributes', async () => { - await supertest - .get('/api/action/_find?search=test.index-record&search_fields=actionTypeId') - .expect(200) - .then((resp: any) => { - expect(resp.body).to.eql({ - page: 1, - perPage: 20, - total: 1, - data: [ - { - id: ES_ARCHIVER_ACTION_ID, - description: 'My action', - actionTypeId: 'test.index-record', - config: { - unencrypted: `This value shouldn't get encrypted`, - }, - }, - ], - }); - }); - }); - }); -} diff --git a/x-pack/test/alerting_api_integration/apis/actions/get.ts b/x-pack/test/alerting_api_integration/apis/actions/get.ts deleted file mode 100644 index be8df1fe076eab..00000000000000 --- a/x-pack/test/alerting_api_integration/apis/actions/get.ts +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import expect from '@kbn/expect'; -import { ES_ARCHIVER_ACTION_ID, SPACE_1_ES_ARCHIVER_ACTION_ID } from './constants'; -import { FtrProviderContext } from '../../ftr_provider_context'; - -export default function getActionTests({ getService }: FtrProviderContext) { - const supertest = getService('supertest'); - const esArchiver = getService('esArchiver'); - - describe('get', () => { - before(() => esArchiver.load('actions/basic')); - after(() => esArchiver.unload('actions/basic')); - - it('should return 200 when finding a record and not return encrypted attributes', async () => { - await supertest - .get(`/api/action/${ES_ARCHIVER_ACTION_ID}`) - .expect(200) - .then((resp: any) => { - expect(resp.body).to.eql({ - id: ES_ARCHIVER_ACTION_ID, - actionTypeId: 'test.index-record', - description: 'My action', - config: { - unencrypted: `This value shouldn't get encrypted`, - }, - }); - }); - }); - - it('should return 404 when finding a record in another space', async () => { - await supertest.get(`/api/action/${SPACE_1_ES_ARCHIVER_ACTION_ID}`).expect(404); - }); - - it('should return 200 when finding a record in a space', async () => { - await supertest - .get(`/s/space_1/api/action/${SPACE_1_ES_ARCHIVER_ACTION_ID}`) - .expect(200) - .then((resp: any) => { - expect(resp.body).to.eql({ - id: SPACE_1_ES_ARCHIVER_ACTION_ID, - actionTypeId: 'test.index-record', - description: 'My action', - config: { - unencrypted: `This value shouldn't get encrypted`, - }, - }); - }); - }); - - it('should return 404 when not finding a record', async () => { - await supertest - .get('/api/action/2') - .expect(404) - .then((resp: any) => { - expect(resp.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Saved object [action/2] not found', - }); - }); - }); - }); -} diff --git a/x-pack/test/alerting_api_integration/apis/actions/list_action_types.ts b/x-pack/test/alerting_api_integration/apis/actions/list_action_types.ts deleted file mode 100644 index b9b04975138b5c..00000000000000 --- a/x-pack/test/alerting_api_integration/apis/actions/list_action_types.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../ftr_provider_context'; - -export default function listActionTypesTests({ getService }: FtrProviderContext) { - const supertest = getService('supertest'); - - describe('list_action_types', () => { - it('should return 200 with list of action types containing defaults', async () => { - await supertest - .get('/api/action/types') - .expect(200) - .then((resp: any) => { - function createActionTypeMatcher(id: string, name: string) { - return (actionType: { id: string; name: string }) => { - return actionType.id === id && actionType.name === name; - }; - } - // Check for values explicitly in order to avoid this test failing each time plugins register - // a new action type - expect( - resp.body.some(createActionTypeMatcher('test.index-record', 'Test: Index Record')) - ).to.be(true); - }); - }); - }); -} diff --git a/x-pack/test/alerting_api_integration/apis/actions/update.ts b/x-pack/test/alerting_api_integration/apis/actions/update.ts deleted file mode 100644 index 8979bbbbd45fda..00000000000000 --- a/x-pack/test/alerting_api_integration/apis/actions/update.ts +++ /dev/null @@ -1,261 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import expect from '@kbn/expect'; -import { ES_ARCHIVER_ACTION_ID, SPACE_1_ES_ARCHIVER_ACTION_ID } from './constants'; -import { FtrProviderContext } from '../../ftr_provider_context'; - -export default function updateActionTests({ getService }: FtrProviderContext) { - const supertest = getService('supertest'); - const esArchiver = getService('esArchiver'); - - describe('update', () => { - beforeEach(() => esArchiver.load('actions/basic')); - afterEach(() => esArchiver.unload('actions/basic')); - - it('should return 200 when updating a document', async () => { - await supertest - .put(`/api/action/${ES_ARCHIVER_ACTION_ID}`) - .set('kbn-xsrf', 'foo') - .send({ - description: 'My action updated', - config: { - unencrypted: `This value shouldn't get encrypted`, - }, - secrets: { - encrypted: 'This value should be encrypted', - }, - }) - .expect(200) - .then((resp: any) => { - expect(resp.body).to.eql({ - id: ES_ARCHIVER_ACTION_ID, - actionTypeId: 'test.index-record', - description: 'My action updated', - config: { - unencrypted: `This value shouldn't get encrypted`, - }, - }); - }); - }); - - it('should return 200 when updating a document in a space', async () => { - await supertest - .put(`/s/space_1/api/action/${SPACE_1_ES_ARCHIVER_ACTION_ID}`) - .set('kbn-xsrf', 'foo') - .send({ - description: 'My action updated', - config: { - unencrypted: `This value shouldn't get encrypted`, - }, - secrets: { - encrypted: 'This value should be encrypted', - }, - }) - .expect(200) - .then((resp: any) => { - expect(resp.body).to.eql({ - id: SPACE_1_ES_ARCHIVER_ACTION_ID, - actionTypeId: 'test.index-record', - description: 'My action updated', - config: { - unencrypted: `This value shouldn't get encrypted`, - }, - }); - }); - }); - - it('should return 404 when updating a document in another space', async () => { - await supertest - .put(`/api/action/${SPACE_1_ES_ARCHIVER_ACTION_ID}`) - .set('kbn-xsrf', 'foo') - .send({ - description: 'My action updated', - config: { - unencrypted: `This value shouldn't get encrypted`, - encrypted: 'This value should be encrypted', - }, - }) - .expect(404); - }); - - it('should not be able to pass null config', async () => { - await supertest - .put(`/api/action/${ES_ARCHIVER_ACTION_ID}`) - .set('kbn-xsrf', 'foo') - .send({ - description: 'My action updated', - config: null, - }) - .expect(400) - .then((resp: any) => { - expect(resp.body).to.eql({ - statusCode: 400, - error: 'Bad Request', - message: 'child "config" fails because ["config" must be an object]', - validation: { - source: 'payload', - keys: ['config'], - }, - }); - }); - }); - - it('should not return encrypted attributes', async () => { - const { body: updatedAction } = await supertest - .put(`/api/action/${ES_ARCHIVER_ACTION_ID}`) - .set('kbn-xsrf', 'foo') - .send({ - description: 'My action updated', - config: { - unencrypted: `This value shouldn't get encrypted`, - }, - secrets: { - encrypted: 'This value should be encrypted', - }, - }) - .expect(200); - expect(updatedAction).to.eql({ - id: ES_ARCHIVER_ACTION_ID, - actionTypeId: 'test.index-record', - description: 'My action updated', - config: { - unencrypted: `This value shouldn't get encrypted`, - }, - }); - const { body: fetchedAction } = await supertest - .get(`/api/action/${ES_ARCHIVER_ACTION_ID}`) - .expect(200); - expect(fetchedAction).to.eql({ - id: ES_ARCHIVER_ACTION_ID, - actionTypeId: 'test.index-record', - description: 'My action updated', - config: { - unencrypted: `This value shouldn't get encrypted`, - }, - }); - }); - - it('should return 404 when updating a non existing document', async () => { - await supertest - .put('/api/action/2') - .set('kbn-xsrf', 'foo') - .send({ - description: 'My action updated', - config: { - unencrypted: `This value shouldn't get encrypted`, - }, - secrets: { - encrypted: 'This value should be encrypted', - }, - }) - .expect(404) - .then((resp: any) => { - expect(resp.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Saved object [action/2] not found', - }); - }); - }); - - it('should return 400 when payload is empty and invalid', async () => { - await supertest - .put(`/api/action/${ES_ARCHIVER_ACTION_ID}`) - .set('kbn-xsrf', 'foo') - .send({}) - .expect(400) - .then((resp: any) => { - expect(resp.body).to.eql({ - statusCode: 400, - error: 'Bad Request', - message: 'child "description" fails because ["description" is required]', - validation: { source: 'payload', keys: ['description'] }, - }); - }); - }); - - it(`should return 400 when secrets are not valid`, async () => { - await supertest - .put(`/api/action/${ES_ARCHIVER_ACTION_ID}`) - .set('kbn-xsrf', 'foo') - .send({ - description: 'My action updated', - config: { - unencrypted: `This value shouldn't get encrypted`, - }, - secrets: { - encrypted: 42, - }, - }) - .expect(400) - .then((resp: any) => { - expect(resp.body).to.eql({ - statusCode: 400, - error: 'Bad Request', - message: - 'error validating action type secrets: [encrypted]: expected value of type [string] but got [number]', - }); - }); - }); - - it(`should allow changing non-secret config properties - create`, async () => { - let emailActionId: string = ''; - - // create the action - await supertest - .post('/api/action') - .set('kbn-xsrf', 'foo') - .send({ - description: 'test email action', - actionTypeId: '.email', - config: { - from: 'email-from@example.com', - host: 'host-is-ignored-here.example.com', - port: 666, - }, - secrets: { - user: 'email-user', - password: 'email-password', - }, - }) - .expect(200) - .then((resp: any) => { - emailActionId = resp.body.id; - }); - - // add a new config param - await supertest - .put(`/api/action/${emailActionId}`) - .set('kbn-xsrf', 'foo') - .send({ - description: 'a test email action 2', - config: { - from: 'email-from@example.com', - service: '__json', - }, - secrets: { - user: 'email-user', - password: 'email-password', - }, - }) - .expect(200); - - // execute the action - await supertest - .post(`/api/action/${emailActionId}/_execute`) - .set('kbn-xsrf', 'foo') - .send({ - params: { - to: ['X'], - subject: 'email-subject', - message: 'email-message', - }, - }) - .expect(200); - }); - }); -} diff --git a/x-pack/test/alerting_api_integration/apis/alerting/alerts.ts b/x-pack/test/alerting_api_integration/apis/alerting/alerts.ts deleted file mode 100644 index 6f1582eff42b64..00000000000000 --- a/x-pack/test/alerting_api_integration/apis/alerting/alerts.ts +++ /dev/null @@ -1,325 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import expect from '@kbn/expect'; -import { getTestAlertData, setupEsTestIndex, destroyEsTestIndex } from './utils'; -import { FtrProviderContext } from '../../ftr_provider_context'; -import { ES_ARCHIVER_ACTION_ID, SPACE_1_ES_ARCHIVER_ACTION_ID } from './constants'; - -export default function alertTests({ getService }: FtrProviderContext) { - const supertest = getService('supertest'); - const esArchiver = getService('esArchiver'); - const es = getService('es'); - const retry = getService('retry'); - - describe('alerts', () => { - let esTestIndexName: string; - const createdAlertIds: Array<{ space: string; id: string }> = []; - - before(async () => { - await destroyEsTestIndex(es); - ({ name: esTestIndexName } = await setupEsTestIndex(es)); - await esArchiver.load('actions/basic'); - }); - after(async () => { - await Promise.all( - createdAlertIds.map(({ space, id }) => { - const urlPrefix = space !== 'default' ? `/s/${space}` : ''; - return supertest - .delete(`${urlPrefix}/api/alert/${id}`) - .set('kbn-xsrf', 'foo') - .expect(204, ''); - }) - ); - await esArchiver.unload('actions/basic'); - await destroyEsTestIndex(es); - }); - - it('should schedule task, run alert and fire actions', async () => { - await supertest - .post('/api/alert') - .set('kbn-xsrf', 'foo') - .send( - getTestAlertData({ - interval: '1s', - alertTypeId: 'test.always-firing', - alertTypeParams: { - index: esTestIndexName, - reference: 'create-test-1', - }, - actions: [ - { - group: 'default', - id: ES_ARCHIVER_ACTION_ID, - params: { - index: esTestIndexName, - reference: 'create-test-1', - message: - 'instanceContextValue: {{context.instanceContextValue}}, instanceStateValue: {{state.instanceStateValue}}', - }, - }, - ], - }) - ) - .expect(200) - .then((resp: any) => { - createdAlertIds.push({ space: 'default', id: resp.body.id }); - }); - const alertTestRecord = await retry.tryForTime(15000, async () => { - const searchResult = await es.search({ - index: esTestIndexName, - body: { - query: { - bool: { - must: [ - { - term: { - source: 'alert:test.always-firing', - }, - }, - { - term: { - reference: 'create-test-1', - }, - }, - ], - }, - }, - }, - }); - expect(searchResult.hits.total.value).to.eql(1); - return searchResult.hits.hits[0]; - }); - expect(alertTestRecord._source).to.eql({ - source: 'alert:test.always-firing', - reference: 'create-test-1', - state: {}, - params: { - index: esTestIndexName, - reference: 'create-test-1', - }, - }); - const actionTestRecord = await retry.tryForTime(15000, async () => { - const searchResult = await es.search({ - index: esTestIndexName, - body: { - query: { - bool: { - must: [ - { - term: { - source: 'action:test.index-record', - }, - }, - { - term: { - reference: 'create-test-1', - }, - }, - ], - }, - }, - }, - }); - expect(searchResult.hits.total.value).to.eql(1); - return searchResult.hits.hits[0]; - }); - expect(actionTestRecord._source).to.eql({ - config: { - unencrypted: `This value shouldn't get encrypted`, - }, - secrets: { - encrypted: 'This value should be encrypted', - }, - params: { - index: esTestIndexName, - reference: 'create-test-1', - message: 'instanceContextValue: true, instanceStateValue: true', - }, - reference: 'create-test-1', - source: 'action:test.index-record', - }); - }); - - it('should schedule task, run alert and fire actions in a space', async () => { - const { body: createdAlert } = await supertest - .post('/s/space_1/api/alert') - .set('kbn-xsrf', 'foo') - .send( - getTestAlertData({ - interval: '1s', - alertTypeId: 'test.always-firing', - alertTypeParams: { - index: esTestIndexName, - reference: 'create-test-2', - }, - actions: [ - { - group: 'default', - id: SPACE_1_ES_ARCHIVER_ACTION_ID, - params: { - index: esTestIndexName, - reference: 'create-test-2', - message: - 'instanceContextValue: {{context.instanceContextValue}}, instanceStateValue: {{state.instanceStateValue}}', - }, - }, - ], - }) - ) - .expect(200); - createdAlertIds.push({ space: 'space_1', id: createdAlert.id }); - - const alertTestRecord = await retry.tryForTime(15000, async () => { - const searchResult = await es.search({ - index: esTestIndexName, - body: { - query: { - bool: { - must: [ - { - term: { - source: 'alert:test.always-firing', - }, - }, - { - term: { - reference: 'create-test-2', - }, - }, - ], - }, - }, - }, - }); - expect(searchResult.hits.total.value).to.eql(1); - return searchResult.hits.hits[0]; - }); - expect(alertTestRecord._source).to.eql({ - source: 'alert:test.always-firing', - reference: 'create-test-2', - state: {}, - params: { - index: esTestIndexName, - reference: 'create-test-2', - }, - }); - const actionTestRecord = await retry.tryForTime(15000, async () => { - const searchResult = await es.search({ - index: esTestIndexName, - body: { - query: { - bool: { - must: [ - { - term: { - source: 'action:test.index-record', - }, - }, - { - term: { - reference: 'create-test-2', - }, - }, - ], - }, - }, - }, - }); - expect(searchResult.hits.total.value).to.eql(1); - return searchResult.hits.hits[0]; - }); - expect(actionTestRecord._source).to.eql({ - config: { - unencrypted: `This value shouldn't get encrypted`, - }, - secrets: { - encrypted: 'This value should be encrypted', - }, - params: { - index: esTestIndexName, - reference: 'create-test-2', - message: 'instanceContextValue: true, instanceStateValue: true', - }, - reference: 'create-test-2', - source: 'action:test.index-record', - }); - }); - - it('should handle custom retry logic', async () => { - // We'll use this start time to query tasks created after this point - const testStart = new Date(); - // We have to provide the test.rate-limit the next runAt, for testing purposes - const retryDate = new Date(Date.now() + 60000); - - const { body: createdAlert } = await supertest - .post('/api/alert') - .set('kbn-xsrf', 'foo') - .send( - getTestAlertData({ - interval: '1m', - alertTypeId: 'test.always-firing', - alertTypeParams: { - index: esTestIndexName, - reference: 'create-test-2', - }, - actions: [ - { - group: 'default', - id: 'ce37997f-0fb6-460a-8baf-f81ac5d38348', - params: { - index: esTestIndexName, - reference: 'create-test-1', - retryAt: retryDate.getTime(), - }, - }, - ], - }) - ) - .expect(200); - createdAlertIds.push({ space: 'default', id: createdAlert.id }); - - const scheduledActionTask = await retry.tryForTime(15000, async () => { - const searchResult = await es.search({ - index: '.kibana_task_manager', - body: { - query: { - bool: { - must: [ - { - term: { - 'task.status': 'idle', - }, - }, - { - term: { - 'task.attempts': 1, - }, - }, - { - term: { - 'task.taskType': 'actions:test.rate-limit', - }, - }, - { - range: { - 'task.scheduledAt': { - gte: testStart, - }, - }, - }, - ], - }, - }, - }, - }); - expect(searchResult.hits.total.value).to.eql(1); - return searchResult.hits.hits[0]; - }); - expect(scheduledActionTask._source.task.runAt).to.eql(retryDate.toISOString()); - }); - }); -} diff --git a/x-pack/test/alerting_api_integration/apis/alerting/constants.ts b/x-pack/test/alerting_api_integration/apis/alerting/constants.ts deleted file mode 100644 index 8289111072b384..00000000000000 --- a/x-pack/test/alerting_api_integration/apis/alerting/constants.ts +++ /dev/null @@ -1,13 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { - ES_ARCHIVER_ACTION_ID as ActionArchiverActionId, - SPACE_1_ES_ARCHIVER_ACTION_ID as ActionArchiverSpace1ActionId, -} from '../actions/constants'; - -export const ES_ARCHIVER_ACTION_ID = ActionArchiverActionId; -export const SPACE_1_ES_ARCHIVER_ACTION_ID = ActionArchiverSpace1ActionId; diff --git a/x-pack/test/alerting_api_integration/apis/alerting/create.ts b/x-pack/test/alerting_api_integration/apis/alerting/create.ts deleted file mode 100644 index 356501f425dff7..00000000000000 --- a/x-pack/test/alerting_api_integration/apis/alerting/create.ts +++ /dev/null @@ -1,217 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import expect from '@kbn/expect'; -import { getTestAlertData } from './utils'; -import { ES_ARCHIVER_ACTION_ID } from './constants'; -import { FtrProviderContext } from '../../ftr_provider_context'; - -export default function createAlertTests({ getService }: FtrProviderContext) { - const supertest = getService('supertest'); - const esArchiver = getService('esArchiver'); - const es = getService('es'); - - describe('create', () => { - const createdAlertIds: Array<{ space: string; id: string }> = []; - - before(() => esArchiver.load('actions/basic')); - after(async () => { - await Promise.all( - createdAlertIds.map(({ space, id }) => { - const urlPrefix = space !== 'default' ? `/s/${space}` : ''; - return supertest - .delete(`${urlPrefix}/api/alert/${id}`) - .set('kbn-xsrf', 'foo') - .expect(204, ''); - }) - ); - await esArchiver.unload('actions/basic'); - }); - - async function getScheduledTask(id: string) { - return await es.get({ - id: `task:${id}`, - index: '.kibana_task_manager', - }); - } - - it('should return 200 when creating an alert', async () => { - await supertest - .post('/api/alert') - .set('kbn-xsrf', 'foo') - .send(getTestAlertData()) - .expect(200) - .then(async (resp: any) => { - createdAlertIds.push({ space: 'default', id: resp.body.id }); - expect(resp.body).to.eql({ - id: resp.body.id, - actions: [ - { - group: 'default', - id: ES_ARCHIVER_ACTION_ID, - params: { - message: - 'instanceContextValue: {{context.instanceContextValue}}, instanceStateValue: {{state.instanceStateValue}}', - }, - }, - ], - enabled: true, - alertTypeId: 'test.noop', - alertTypeParams: {}, - interval: '10s', - scheduledTaskId: resp.body.scheduledTaskId, - }); - expect(typeof resp.body.scheduledTaskId).to.be('string'); - const { _source: taskRecord } = await getScheduledTask(resp.body.scheduledTaskId); - expect(taskRecord.type).to.eql('task'); - expect(taskRecord.task.taskType).to.eql('alerting:test.noop'); - expect(JSON.parse(taskRecord.task.params)).to.eql({ - alertId: resp.body.id, - spaceId: 'default', - }); - }); - }); - - it('should return 200 when creating an alert in a space', async () => { - const { body: createdAlert } = await supertest - .post('/s/space_1/api/alert') - .set('kbn-xsrf', 'foo') - .send(getTestAlertData()) - .expect(200); - createdAlertIds.push({ space: 'space_1', id: createdAlert.id }); - expect(createdAlert).to.eql({ - id: createdAlert.id, - actions: [ - { - group: 'default', - id: ES_ARCHIVER_ACTION_ID, - params: { - message: - 'instanceContextValue: {{context.instanceContextValue}}, instanceStateValue: {{state.instanceStateValue}}', - }, - }, - ], - enabled: true, - alertTypeId: 'test.noop', - alertTypeParams: {}, - interval: '10s', - scheduledTaskId: createdAlert.scheduledTaskId, - }); - expect(typeof createdAlert.scheduledTaskId).to.be('string'); - const { _source: taskRecord } = await getScheduledTask(createdAlert.scheduledTaskId); - expect(taskRecord.type).to.eql('task'); - expect(taskRecord.task.taskType).to.eql('alerting:test.noop'); - expect(JSON.parse(taskRecord.task.params)).to.eql({ - alertId: createdAlert.id, - spaceId: 'space_1', - }); - }); - - it('should not schedule a task when creating a disabled alert', async () => { - const { body: createdAlert } = await supertest - .post('/api/alert') - .set('kbn-xsrf', 'foo') - .send(getTestAlertData({ enabled: false })) - .expect(200); - expect(createdAlert.scheduledTaskId).to.eql(undefined); - }); - - it(`should return 400 when alert type isn't registered`, async () => { - await supertest - .post('/api/alert') - .set('kbn-xsrf', 'foo') - .send( - getTestAlertData({ - alertTypeId: 'test.unregistered-alert-type', - }) - ) - .expect(400) - .then((resp: any) => { - expect(resp.body).to.eql({ - statusCode: 400, - error: 'Bad Request', - message: 'Alert type "test.unregistered-alert-type" is not registered.', - }); - }); - }); - - it('should return 400 when payload is empty and invalid', async () => { - await supertest - .post('/api/alert') - .set('kbn-xsrf', 'foo') - .send({}) - .expect(400) - .then((resp: any) => { - expect(resp.body).to.eql({ - statusCode: 400, - error: 'Bad Request', - message: - 'child "alertTypeId" fails because ["alertTypeId" is required]. child "interval" fails because ["interval" is required]. child "alertTypeParams" fails because ["alertTypeParams" is required]. child "actions" fails because ["actions" is required]', - validation: { - source: 'payload', - keys: ['alertTypeId', 'interval', 'alertTypeParams', 'actions'], - }, - }); - }); - }); - - it(`should return 400 when alertTypeParams isn't valid`, async () => { - await supertest - .post('/api/alert') - .set('kbn-xsrf', 'foo') - .send( - getTestAlertData({ - alertTypeId: 'test.validation', - }) - ) - .expect(400) - .then((resp: any) => { - expect(resp.body).to.eql({ - statusCode: 400, - error: 'Bad Request', - message: - 'alertTypeParams invalid: [param1]: expected value of type [string] but got [undefined]', - }); - }); - }); - - it(`should return 400 when interval is wrong syntax`, async () => { - const { body: error } = await supertest - .post('/api/alert') - .set('kbn-xsrf', 'foo') - .send(getTestAlertData({ interval: '10x' })) - .expect(400); - expect(error).to.eql({ - statusCode: 400, - error: 'Bad Request', - message: - 'child "interval" fails because ["interval" with value "10x" fails to match the seconds (5s) pattern, "interval" with value "10x" fails to match the minutes (5m) pattern, "interval" with value "10x" fails to match the hours (5h) pattern, "interval" with value "10x" fails to match the days (5d) pattern]', - validation: { - source: 'payload', - keys: ['interval', 'interval', 'interval', 'interval'], - }, - }); - }); - - it(`should return 400 when interval is 0`, async () => { - const { body: error } = await supertest - .post('/api/alert') - .set('kbn-xsrf', 'foo') - .send(getTestAlertData({ interval: '0s' })) - .expect(400); - expect(error).to.eql({ - statusCode: 400, - error: 'Bad Request', - message: - 'child "interval" fails because ["interval" with value "0s" fails to match the seconds (5s) pattern, "interval" with value "0s" fails to match the minutes (5m) pattern, "interval" with value "0s" fails to match the hours (5h) pattern, "interval" with value "0s" fails to match the days (5d) pattern]', - validation: { - source: 'payload', - keys: ['interval', 'interval', 'interval', 'interval'], - }, - }); - }); - }); -} diff --git a/x-pack/test/alerting_api_integration/apis/alerting/delete.ts b/x-pack/test/alerting_api_integration/apis/alerting/delete.ts deleted file mode 100644 index ec661cac555433..00000000000000 --- a/x-pack/test/alerting_api_integration/apis/alerting/delete.ts +++ /dev/null @@ -1,89 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import expect from '@kbn/expect'; -import { getTestAlertData } from './utils'; -import { FtrProviderContext } from '../../ftr_provider_context'; - -export default function createDeleteTests({ getService }: FtrProviderContext) { - const supertest = getService('supertest'); - const esArchiver = getService('esArchiver'); - const es = getService('es'); - - describe('delete', () => { - let alertId: string; - let scheduledTaskId: string; - let space1AlertId: string; - let space1ScheduledTaskId: string; - - before(async () => { - await esArchiver.load('actions/basic'); - await supertest - .post('/api/alert') - .set('kbn-xsrf', 'foo') - .send(getTestAlertData()) - .expect(200) - .then((resp: any) => { - alertId = resp.body.id; - scheduledTaskId = resp.body.scheduledTaskId; - }); - await supertest - .post('/s/space_1/api/alert') - .set('kbn-xsrf', 'foo') - .send(getTestAlertData()) - .expect(200) - .then((resp: any) => { - space1AlertId = resp.body.id; - space1ScheduledTaskId = resp.body.scheduledTaskId; - }); - }); - after(() => esArchiver.unload('actions/basic')); - - async function getScheduledTask(id: string) { - return await es.get({ - id, - index: '.kibana_task_manager', - }); - } - - it('should return 204 when deleting an alert and removing scheduled task', async () => { - await supertest - .delete(`/api/alert/${alertId}`) - .set('kbn-xsrf', 'foo') - .expect(204, ''); - let hasThrownError = false; - try { - await getScheduledTask(scheduledTaskId); - } catch (e) { - hasThrownError = true; - expect(e.status).to.eql(404); - } - expect(hasThrownError).to.eql(true); - }); - - it('should return 404 when deleting an alert from another space', async () => { - await supertest - .delete(`/api/alert/${space1AlertId}`) - .set('kbn-xsrf', 'foo') - .expect(404); - }); - - it('should return 204 when deleting an alert in a space', async () => { - await supertest - .delete(`/s/space_1/api/alert/${space1AlertId}`) - .set('kbn-xsrf', 'foo') - .expect(204, ''); - let hasThrownError = false; - try { - await getScheduledTask(space1ScheduledTaskId); - } catch (e) { - hasThrownError = true; - expect(e.status).to.eql(404); - } - expect(hasThrownError).to.eql(true); - }); - }); -} diff --git a/x-pack/test/alerting_api_integration/apis/alerting/disable.ts b/x-pack/test/alerting_api_integration/apis/alerting/disable.ts deleted file mode 100644 index f73398674bb876..00000000000000 --- a/x-pack/test/alerting_api_integration/apis/alerting/disable.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { getTestAlertData } from './utils'; -import { FtrProviderContext } from '../../ftr_provider_context'; - -export default function createDisableAlertTests({ getService }: FtrProviderContext) { - const supertest = getService('supertest'); - const esArchiver = getService('esArchiver'); - - describe('disable', () => { - let alertId: string; - let space1AlertId: string; - - before(async () => { - await esArchiver.load('actions/basic'); - await supertest - .post('/api/alert') - .set('kbn-xsrf', 'foo') - .send(getTestAlertData({ enabled: true })) - .expect(200) - .then((resp: any) => { - alertId = resp.body.id; - }); - await supertest - .post('/s/space_1/api/alert') - .set('kbn-xsrf', 'foo') - .send(getTestAlertData({ enabled: true })) - .expect(200) - .then((resp: any) => { - space1AlertId = resp.body.id; - }); - }); - - after(async () => { - await supertest - .delete(`/api/alert/${alertId}`) - .set('kbn-xsrf', 'foo') - .expect(204, ''); - await supertest - .delete(`/s/space_1/api/alert/${space1AlertId}`) - .set('kbn-xsrf', 'foo') - .expect(204, ''); - await esArchiver.unload('actions/basic'); - }); - - it('should return 204 when disabling an alert', async () => { - await supertest - .post(`/api/alert/${alertId}/_disable`) - .set('kbn-xsrf', 'foo') - .expect(204); - }); - - it('should return 404 when disabling an alert from another space', async () => { - await supertest - .post(`/api/alert/${space1AlertId}/_disable`) - .set('kbn-xsrf', 'foo') - .expect(404); - }); - - it('should return 204 when disabling an alert in a space', async () => { - await supertest - .post(`/s/space_1/api/alert/${space1AlertId}/_disable`) - .set('kbn-xsrf', 'foo') - .expect(204); - }); - }); -} diff --git a/x-pack/test/alerting_api_integration/apis/alerting/enable.ts b/x-pack/test/alerting_api_integration/apis/alerting/enable.ts deleted file mode 100644 index da58080499c66b..00000000000000 --- a/x-pack/test/alerting_api_integration/apis/alerting/enable.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { getTestAlertData } from './utils'; -import { FtrProviderContext } from '../../ftr_provider_context'; - -export default function createEnableAlertTests({ getService }: FtrProviderContext) { - const supertest = getService('supertest'); - const esArchiver = getService('esArchiver'); - - describe('enable', () => { - let alertId: string; - let space1AlertId: string; - - before(async () => { - await esArchiver.load('actions/basic'); - await supertest - .post('/api/alert') - .set('kbn-xsrf', 'foo') - .send(getTestAlertData({ enabled: false })) - .expect(200) - .then((resp: any) => { - alertId = resp.body.id; - }); - await supertest - .post('/s/space_1/api/alert') - .set('kbn-xsrf', 'foo') - .send(getTestAlertData({ enabled: false })) - .expect(200) - .then((resp: any) => { - space1AlertId = resp.body.id; - }); - }); - - after(async () => { - await supertest - .delete(`/api/alert/${alertId}`) - .set('kbn-xsrf', 'foo') - .expect(204, ''); - await supertest - .delete(`/s/space_1/api/alert/${space1AlertId}`) - .set('kbn-xsrf', 'foo') - .expect(204, ''); - await esArchiver.unload('actions/basic'); - }); - - it('should return 204 when enabling an alert', async () => { - await supertest - .post(`/api/alert/${alertId}/_enable`) - .set('kbn-xsrf', 'foo') - .expect(204); - }); - - it('should return 404 when enabling an alert from another space', async () => { - await supertest - .post(`/api/alert/${space1AlertId}/_enable`) - .set('kbn-xsrf', 'foo') - .expect(404); - }); - - it('should return 204 when enabling an alert in a space', async () => { - await supertest - .post(`/s/space_1/api/alert/${space1AlertId}/_enable`) - .set('kbn-xsrf', 'foo') - .expect(204); - }); - }); -} diff --git a/x-pack/test/alerting_api_integration/apis/alerting/find.ts b/x-pack/test/alerting_api_integration/apis/alerting/find.ts deleted file mode 100644 index 59b9a42490fe5d..00000000000000 --- a/x-pack/test/alerting_api_integration/apis/alerting/find.ts +++ /dev/null @@ -1,109 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import expect from '@kbn/expect'; -import { getTestAlertData } from './utils'; -import { ES_ARCHIVER_ACTION_ID } from './constants'; -import { FtrProviderContext } from '../../ftr_provider_context'; - -export default function createFindTests({ getService }: FtrProviderContext) { - const supertest = getService('supertest'); - const esArchiver = getService('esArchiver'); - - describe('find', () => { - let alertId: string; - let space1AlertId: string; - - before(async () => { - await esArchiver.load('actions/basic'); - await supertest - .post('/api/alert') - .set('kbn-xsrf', 'foo') - .send(getTestAlertData()) - .expect(200) - .then((resp: any) => { - alertId = resp.body.id; - }); - await supertest - .post('/s/space_1/api/alert') - .set('kbn-xsrf', 'foo') - .send(getTestAlertData()) - .expect(200) - .then((resp: any) => { - space1AlertId = resp.body.id; - }); - }); - after(async () => { - await supertest - .delete(`/api/alert/${alertId}`) - .set('kbn-xsrf', 'foo') - .expect(204, ''); - await supertest - .delete(`/s/space_1/api/alert/${space1AlertId}`) - .set('kbn-xsrf', 'foo') - .expect(204, ''); - await esArchiver.unload('actions/basic'); - }); - - it('should return 200 when finding alerts', async () => { - await supertest - .get('/api/alert/_find') - .expect(200) - .then((resp: any) => { - const body = resp.body; - expect(body.page).to.equal(1); - expect(body.perPage).to.be.greaterThan(0); - expect(body.total).to.be.greaterThan(0); - const match = body.data.find((obj: any) => obj.id === alertId); - expect(match).to.eql({ - id: alertId, - alertTypeId: 'test.noop', - interval: '10s', - enabled: true, - actions: [ - { - group: 'default', - id: ES_ARCHIVER_ACTION_ID, - params: { - message: - 'instanceContextValue: {{context.instanceContextValue}}, instanceStateValue: {{state.instanceStateValue}}', - }, - }, - ], - alertTypeParams: {}, - scheduledTaskId: match.scheduledTaskId, - }); - }); - }); - - it('should return 200 when finding alerts in a space', async () => { - await supertest - .get('/s/space_1/api/alert/_find') - .expect(200) - .then((resp: any) => { - const match = resp.body.data.find((obj: any) => obj.id === space1AlertId); - expect(match).to.eql({ - id: space1AlertId, - alertTypeId: 'test.noop', - interval: '10s', - enabled: true, - actions: [ - { - group: 'default', - id: ES_ARCHIVER_ACTION_ID, - params: { - message: - 'instanceContextValue: {{context.instanceContextValue}}, instanceStateValue: {{state.instanceStateValue}}', - }, - }, - ], - alertTypeParams: {}, - scheduledTaskId: match.scheduledTaskId, - }); - }); - }); - }); -} diff --git a/x-pack/test/alerting_api_integration/apis/alerting/get.ts b/x-pack/test/alerting_api_integration/apis/alerting/get.ts deleted file mode 100644 index 8f2dffe37e335c..00000000000000 --- a/x-pack/test/alerting_api_integration/apis/alerting/get.ts +++ /dev/null @@ -1,107 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import expect from '@kbn/expect'; -import { getTestAlertData } from './utils'; -import { ES_ARCHIVER_ACTION_ID } from './constants'; -import { FtrProviderContext } from '../../ftr_provider_context'; - -export default function createGetTests({ getService }: FtrProviderContext) { - const supertest = getService('supertest'); - const esArchiver = getService('esArchiver'); - - describe('get', () => { - let alertId: string; - let space1AlertId: string; - - before(async () => { - await esArchiver.load('actions/basic'); - await supertest - .post('/api/alert') - .set('kbn-xsrf', 'foo') - .send(getTestAlertData()) - .expect(200) - .then((resp: any) => { - alertId = resp.body.id; - }); - await supertest - .post('/s/space_1/api/alert') - .set('kbn-xsrf', 'foo') - .send(getTestAlertData()) - .expect(200) - .then((resp: any) => { - space1AlertId = resp.body.id; - }); - }); - after(async () => { - await supertest - .delete(`/api/alert/${alertId}`) - .set('kbn-xsrf', 'foo') - .expect(204, ''); - await supertest - .delete(`/s/space_1/api/alert/${space1AlertId}`) - .set('kbn-xsrf', 'foo') - .expect(204, ''); - await esArchiver.unload('actions/basic'); - }); - - it('should return 200 when getting an alert', async () => { - await supertest - .get(`/api/alert/${alertId}`) - .expect(200) - .then((resp: any) => { - expect(resp.body).to.eql({ - id: alertId, - alertTypeId: 'test.noop', - interval: '10s', - enabled: true, - actions: [ - { - group: 'default', - id: ES_ARCHIVER_ACTION_ID, - params: { - message: - 'instanceContextValue: {{context.instanceContextValue}}, instanceStateValue: {{state.instanceStateValue}}', - }, - }, - ], - alertTypeParams: {}, - scheduledTaskId: resp.body.scheduledTaskId, - }); - }); - }); - - it('should return 404 when gettin an alert in another space', async () => { - await supertest.get(`/api/alert/${space1AlertId}`).expect(404); - }); - - it('should return 200 when getting an alert in a space', async () => { - await supertest - .get(`/s/space_1/api/alert/${space1AlertId}`) - .expect(200) - .then((resp: any) => { - expect(resp.body).to.eql({ - id: space1AlertId, - alertTypeId: 'test.noop', - interval: '10s', - enabled: true, - actions: [ - { - group: 'default', - id: ES_ARCHIVER_ACTION_ID, - params: { - message: - 'instanceContextValue: {{context.instanceContextValue}}, instanceStateValue: {{state.instanceStateValue}}', - }, - }, - ], - alertTypeParams: {}, - scheduledTaskId: resp.body.scheduledTaskId, - }); - }); - }); - }); -} diff --git a/x-pack/test/alerting_api_integration/apis/alerting/list_alert_types.ts b/x-pack/test/alerting_api_integration/apis/alerting/list_alert_types.ts deleted file mode 100644 index 85c3664303e1ac..00000000000000 --- a/x-pack/test/alerting_api_integration/apis/alerting/list_alert_types.ts +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../ftr_provider_context'; - -export default function listAlertTypes({ getService }: FtrProviderContext) { - const supertest = getService('supertest'); - - describe('list_alert_types', () => { - it('should return 200 with list of alert types', async () => { - await supertest - .get('/api/alert/types') - .expect(200) - .then((resp: any) => { - const fixtureAlertType = resp.body.find((alertType: any) => alertType.id === 'test.noop'); - expect(fixtureAlertType).to.eql({ - id: 'test.noop', - name: 'Test: Noop', - }); - }); - }); - }); -} diff --git a/x-pack/test/alerting_api_integration/apis/alerting/update.ts b/x-pack/test/alerting_api_integration/apis/alerting/update.ts deleted file mode 100644 index 9ab30bbdc33e01..00000000000000 --- a/x-pack/test/alerting_api_integration/apis/alerting/update.ts +++ /dev/null @@ -1,239 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import expect from '@kbn/expect'; -import { getTestAlertData } from './utils'; -import { ES_ARCHIVER_ACTION_ID, SPACE_1_ES_ARCHIVER_ACTION_ID } from './constants'; -import { FtrProviderContext } from '../../ftr_provider_context'; - -export default function createUpdateTests({ getService }: FtrProviderContext) { - const supertest = getService('supertest'); - const esArchiver = getService('esArchiver'); - - describe('update', () => { - let alertId: string; - let space1AlertId: string; - - before(async () => { - await esArchiver.load('actions/basic'); - await supertest - .post('/api/alert') - .set('kbn-xsrf', 'foo') - .send(getTestAlertData()) - .expect(200) - .then((resp: any) => { - alertId = resp.body.id; - }); - await supertest - .post('/s/space_1/api/alert') - .set('kbn-xsrf', 'foo') - .send(getTestAlertData()) - .expect(200) - .then((resp: any) => { - space1AlertId = resp.body.id; - }); - }); - after(async () => { - await supertest - .delete(`/api/alert/${alertId}`) - .set('kbn-xsrf', 'foo') - .expect(204, ''); - await supertest - .delete(`/s/space_1/api/alert/${space1AlertId}`) - .set('kbn-xsrf', 'foo') - .expect(204, ''); - await esArchiver.unload('actions/basic'); - }); - - it('should return 200 when updating an alert', async () => { - const alert = { - alertTypeParams: { - foo: true, - }, - interval: '12s', - actions: [ - { - group: 'default', - id: ES_ARCHIVER_ACTION_ID, - params: { - message: - 'UPDATED: instanceContextValue: {{context.instanceContextValue}}, instanceStateValue: {{state.instanceStateValue}}', - }, - }, - ], - }; - await supertest - .put(`/api/alert/${alertId}`) - .set('kbn-xsrf', 'foo') - .send(alert) - .expect(200) - .then((resp: any) => { - expect(resp.body).to.eql({ - ...alert, - id: alertId, - }); - }); - }); - - it('should return 404 when updating an alert from another space', async () => { - await supertest - .put(`/api/alert/${space1AlertId}`) - .set('kbn-xsrf', 'foo') - .send({ - alertTypeParams: { - foo: true, - }, - interval: '12s', - actions: [ - { - group: 'default', - id: ES_ARCHIVER_ACTION_ID, - params: { - message: - 'UPDATED: instanceContextValue: {{context.instanceContextValue}}, instanceStateValue: {{state.instanceStateValue}}', - }, - }, - ], - }) - .expect(404); - }); - - it('should return 200 when updating an alert in a space', async () => { - const alert = { - alertTypeParams: { - foo: true, - }, - interval: '12s', - actions: [ - { - group: 'default', - id: SPACE_1_ES_ARCHIVER_ACTION_ID, - params: { - message: - 'UPDATED: instanceContextValue: {{context.instanceContextValue}}, instanceStateValue: {{state.instanceStateValue}}', - }, - }, - ], - }; - await supertest - .put(`/s/space_1/api/alert/${space1AlertId}`) - .set('kbn-xsrf', 'foo') - .send(alert) - .expect(200) - .then((resp: any) => { - expect(resp.body).to.eql({ - ...alert, - id: space1AlertId, - }); - }); - }); - - it('should return 400 when attempting to change alert type', async () => { - await supertest - .put(`/api/alert/${alertId}`) - .set('kbn-xsrf', 'foo') - .send({ - alertTypeId: '1', - alertTypeParams: { - foo: true, - }, - interval: '12s', - actions: [ - { - group: 'default', - id: ES_ARCHIVER_ACTION_ID, - params: { - message: - 'UPDATED: instanceContextValue: {{context.instanceContextValue}}, instanceStateValue: {{state.instanceStateValue}}', - }, - }, - ], - }) - .expect(400) - .then((resp: any) => { - expect(resp.body).to.eql({ - statusCode: 400, - error: 'Bad Request', - message: '"alertTypeId" is not allowed', - validation: { - source: 'payload', - keys: ['alertTypeId'], - }, - }); - }); - }); - - it('should return 400 when payload is empty and invalid', async () => { - await supertest - .put(`/api/alert/${alertId}`) - .set('kbn-xsrf', 'foo') - .send({}) - .expect(400) - .then((resp: any) => { - expect(resp.body).to.eql({ - statusCode: 400, - error: 'Bad Request', - message: - 'child "interval" fails because ["interval" is required]. child "alertTypeParams" fails because ["alertTypeParams" is required]. child "actions" fails because ["actions" is required]', - validation: { - source: 'payload', - keys: ['interval', 'alertTypeParams', 'actions'], - }, - }); - }); - }); - - it(`should return 400 when alertTypeConfig isn't valid`, async () => { - const { body: customAlert } = await supertest - .post('/api/alert') - .set('kbn-xsrf', 'foo') - .send( - getTestAlertData({ - alertTypeId: 'test.validation', - alertTypeParams: { - param1: 'test', - }, - }) - ) - .expect(200); - await supertest - .put(`/api/alert/${customAlert.id}`) - .set('kbn-xsrf', 'foo') - .send({ - interval: '10s', - alertTypeParams: {}, - actions: [], - }) - .expect(400) - .then((resp: any) => { - expect(resp.body).to.eql({ - statusCode: 400, - error: 'Bad Request', - message: - 'alertTypeParams invalid: [param1]: expected value of type [string] but got [undefined]', - }); - }); - }); - - it(`should return 400 when interval is wrong syntax`, async () => { - const { body: error } = await supertest - .put(`/api/alert/${alertId}`) - .set('kbn-xsrf', 'foo') - .send(getTestAlertData({ interval: '10x', enabled: undefined })) - .expect(400); - expect(error).to.eql({ - statusCode: 400, - error: 'Bad Request', - message: - 'child "interval" fails because ["interval" with value "10x" fails to match the seconds pattern, "interval" with value "10x" fails to match the minutes pattern, "interval" with value "10x" fails to match the hours pattern, "interval" with value "10x" fails to match the days pattern]. "alertTypeId" is not allowed', - validation: { - source: 'payload', - keys: ['interval', 'interval', 'interval', 'interval', 'alertTypeId'], - }, - }); - }); - }); -} diff --git a/x-pack/test/alerting_api_integration/common/config.ts b/x-pack/test/alerting_api_integration/common/config.ts new file mode 100644 index 00000000000000..ed79f391a6bacf --- /dev/null +++ b/x-pack/test/alerting_api_integration/common/config.ts @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import path from 'path'; +import { CA_CERT_PATH } from '@kbn/dev-utils'; +import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; +import { services } from './services'; +import { SLACK_ACTION_SIMULATOR_URI } from './fixtures/plugins/actions'; + +interface CreateTestConfigOptions { + license: string; + disabledPlugins?: string[]; + ssl?: boolean; +} + +// eslint-disable-next-line import/no-default-export +export function createTestConfig(name: string, options: CreateTestConfigOptions) { + const { license = 'trial', disabledPlugins = [], ssl = false } = options; + + return async ({ readConfigFile }: FtrConfigProviderContext) => { + const xPackApiIntegrationTestsConfig = await readConfigFile( + require.resolve('../../api_integration/config.js') + ); + const servers = { + ...xPackApiIntegrationTestsConfig.get('servers'), + elasticsearch: { + ...xPackApiIntegrationTestsConfig.get('servers.elasticsearch'), + protocol: ssl ? 'https' : 'http', + }, + }; + + return { + testFiles: [require.resolve(`../${name}/tests/`)], + servers, + services, + junit: { + reportName: 'X-Pack Alerting API Integration Tests', + }, + esArchiver: xPackApiIntegrationTestsConfig.get('esArchiver'), + esTestCluster: { + ...xPackApiIntegrationTestsConfig.get('esTestCluster'), + license, + ssl, + serverArgs: [ + `xpack.license.self_generated.type=${license}`, + `xpack.security.enabled=${!disabledPlugins.includes('security') && license === 'trial'}`, + ], + }, + kbnTestServer: { + ...xPackApiIntegrationTestsConfig.get('kbnTestServer'), + serverArgs: [ + ...xPackApiIntegrationTestsConfig.get('kbnTestServer.serverArgs'), + ...disabledPlugins.map(key => `--xpack.${key}.enabled=false`), + `--plugin-path=${path.join(__dirname, 'fixtures', 'plugins', 'alerts')}`, + `--plugin-path=${path.join(__dirname, 'fixtures', 'plugins', 'actions')}`, + `--server.xsrf.whitelist=${JSON.stringify([SLACK_ACTION_SIMULATOR_URI])}`, + ...(ssl + ? [ + `--elasticsearch.hosts=${servers.elasticsearch.protocol}://${servers.elasticsearch.hostname}:${servers.elasticsearch.port}`, + `--elasticsearch.ssl.certificateAuthorities=${CA_CERT_PATH}`, + ] + : []), + ], + }, + }; + }; +} diff --git a/x-pack/test/alerting_api_integration/fixtures/plugins/actions/README.md b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/README.md similarity index 100% rename from x-pack/test/alerting_api_integration/fixtures/plugins/actions/README.md rename to x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/README.md diff --git a/x-pack/test/alerting_api_integration/fixtures/plugins/actions/index.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/index.ts similarity index 100% rename from x-pack/test/alerting_api_integration/fixtures/plugins/actions/index.ts rename to x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/index.ts diff --git a/x-pack/test/alerting_api_integration/fixtures/plugins/actions/package.json b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/package.json similarity index 100% rename from x-pack/test/alerting_api_integration/fixtures/plugins/actions/package.json rename to x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/package.json diff --git a/x-pack/test/alerting_api_integration/fixtures/plugins/alerts/index.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/index.ts similarity index 60% rename from x-pack/test/alerting_api_integration/fixtures/plugins/alerts/index.ts rename to x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/index.ts index b0e03f3958217d..2554181e2221f4 100644 --- a/x-pack/test/alerting_api_integration/fixtures/plugins/alerts/index.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/index.ts @@ -5,8 +5,8 @@ */ import { schema } from '@kbn/config-schema'; -import { AlertExecutorOptions, AlertType } from '../../../../../legacy/plugins/alerting'; -import { ActionTypeExecutorOptions, ActionType } from '../../../../../legacy/plugins/actions'; +import { AlertExecutorOptions, AlertType } from '../../../../../../legacy/plugins/alerting'; +import { ActionTypeExecutorOptions, ActionType } from '../../../../../../legacy/plugins/actions'; // eslint-disable-next-line import/no-default-export export default function(kibana: any) { @@ -97,9 +97,71 @@ export default function(kibana: any) { }; }, }; + const authorizationActionType: ActionType = { + id: 'test.authorization', + name: 'Test: Authorization', + validate: { + params: schema.object({ + callClusterAuthorizationIndex: schema.string(), + savedObjectsClientType: schema.string(), + savedObjectsClientId: schema.string(), + index: schema.string(), + reference: schema.string(), + }), + }, + async executor({ params, services }: ActionTypeExecutorOptions) { + // Call cluster + let callClusterSuccess = false; + let callClusterError; + try { + await services.callCluster('index', { + index: params.callClusterAuthorizationIndex, + refresh: 'wait_for', + body: { + param1: 'test', + }, + }); + callClusterSuccess = true; + } catch (e) { + callClusterError = e; + } + // Saved objects client + let savedObjectsClientSuccess = false; + let savedObjectsClientError; + try { + await services.savedObjectsClient.get( + params.savedObjectsClientType, + params.savedObjectsClientId + ); + savedObjectsClientSuccess = true; + } catch (e) { + savedObjectsClientError = e; + } + // Save the result + await services.callCluster('index', { + index: params.index, + refresh: 'wait_for', + body: { + state: { + callClusterSuccess, + callClusterError, + savedObjectsClientSuccess, + savedObjectsClientError, + }, + params, + reference: params.reference, + source: 'action:test.authorization', + }, + }); + return { + status: 'ok', + }; + }, + }; server.plugins.actions.registerType(indexRecordActionType); server.plugins.actions.registerType(failingActionType); server.plugins.actions.registerType(rateLimitedActionType); + server.plugins.actions.registerType(authorizationActionType); // Alert types const alwaysFiringAlertType: AlertType = { @@ -164,6 +226,64 @@ export default function(kibana: any) { throw new Error('Failed to execute alert type'); }, }; + const authorizationAlertType: AlertType = { + id: 'test.authorization', + name: 'Test: Authorization', + validate: { + params: schema.object({ + callClusterAuthorizationIndex: schema.string(), + savedObjectsClientType: schema.string(), + savedObjectsClientId: schema.string(), + index: schema.string(), + reference: schema.string(), + }), + }, + async executor({ services, params, state }: AlertExecutorOptions) { + // Call cluster + let callClusterSuccess = false; + let callClusterError; + try { + await services.callCluster('index', { + index: params.callClusterAuthorizationIndex, + refresh: 'wait_for', + body: { + param1: 'test', + }, + }); + callClusterSuccess = true; + } catch (e) { + callClusterError = e; + } + // Saved objects client + let savedObjectsClientSuccess = false; + let savedObjectsClientError; + try { + await services.savedObjectsClient.get( + params.savedObjectsClientType, + params.savedObjectsClientId + ); + savedObjectsClientSuccess = true; + } catch (e) { + savedObjectsClientError = e; + } + // Save the result + await services.callCluster('index', { + index: params.index, + refresh: 'wait_for', + body: { + state: { + callClusterSuccess, + callClusterError, + savedObjectsClientSuccess, + savedObjectsClientError, + }, + params, + reference: params.reference, + source: 'alert:test.authorization', + }, + }); + }, + }; const validationAlertType: AlertType = { id: 'test.validation', name: 'Test: Validation', @@ -183,6 +303,7 @@ export default function(kibana: any) { server.plugins.alerting.registerType(neverFiringAlertType); server.plugins.alerting.registerType(failingAlertType); server.plugins.alerting.registerType(validationAlertType); + server.plugins.alerting.registerType(authorizationAlertType); server.plugins.alerting.registerType(noopAlertType); }, }); diff --git a/x-pack/test/alerting_api_integration/fixtures/plugins/alerts/package.json b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/package.json similarity index 100% rename from x-pack/test/alerting_api_integration/fixtures/plugins/alerts/package.json rename to x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/package.json diff --git a/x-pack/test/alerting_api_integration/ftr_provider_context.d.ts b/x-pack/test/alerting_api_integration/common/ftr_provider_context.d.ts similarity index 100% rename from x-pack/test/alerting_api_integration/ftr_provider_context.d.ts rename to x-pack/test/alerting_api_integration/common/ftr_provider_context.d.ts diff --git a/x-pack/test/alerting_api_integration/apis/actions/constants.ts b/x-pack/test/alerting_api_integration/common/lib/index.ts similarity index 60% rename from x-pack/test/alerting_api_integration/apis/actions/constants.ts rename to x-pack/test/alerting_api_integration/common/lib/index.ts index b7c391507dcc22..7e9713ad14f936 100644 --- a/x-pack/test/alerting_api_integration/apis/actions/constants.ts +++ b/x-pack/test/alerting_api_integration/common/lib/index.ts @@ -4,5 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ -export const ES_ARCHIVER_ACTION_ID = '60071910-1ef1-4a72-9a68-b94af6a836a7'; -export const SPACE_1_ES_ARCHIVER_ACTION_ID = '6c7d0f6b-2fb5-4821-b182-624fc3ccc7a3'; +export { ObjectRemover } from './object_remover'; +export { getUrlPrefix } from './space_test_utils'; diff --git a/x-pack/test/alerting_api_integration/common/lib/object_remover.ts b/x-pack/test/alerting_api_integration/common/lib/object_remover.ts new file mode 100644 index 00000000000000..8d01b3444b4881 --- /dev/null +++ b/x-pack/test/alerting_api_integration/common/lib/object_remover.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getUrlPrefix } from './space_test_utils'; + +interface ObjectToRemove { + spaceId: string; + id: string; + type: string; +} + +export class ObjectRemover { + private readonly supertest: any; + private objectsToRemove: ObjectToRemove[] = []; + + constructor(supertest: any) { + this.supertest = supertest; + } + + add(spaceId: ObjectToRemove['spaceId'], id: ObjectToRemove['id'], type: ObjectToRemove['type']) { + this.objectsToRemove.push({ spaceId, id, type }); + } + + async removeAll() { + await Promise.all( + this.objectsToRemove.map(({ spaceId, id, type }) => { + return this.supertest + .delete(`${getUrlPrefix(spaceId)}/api/${type}/${id}`) + .set('kbn-xsrf', 'foo') + .expect(204); + }) + ); + this.objectsToRemove = []; + } +} diff --git a/x-pack/test/alerting_api_integration/apis/index.js b/x-pack/test/alerting_api_integration/common/lib/space_test_utils.ts similarity index 53% rename from x-pack/test/alerting_api_integration/apis/index.js rename to x-pack/test/alerting_api_integration/common/lib/space_test_utils.ts index dc2cd54dcd22bd..f355f99e0df07f 100644 --- a/x-pack/test/alerting_api_integration/apis/index.js +++ b/x-pack/test/alerting_api_integration/common/lib/space_test_utils.ts @@ -4,11 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -export default function ({ loadTestFile }) { - describe('apis', function () { - this.tags('ciGroup8'); - - loadTestFile(require.resolve('./actions')); - loadTestFile(require.resolve('./alerting')); - }); +export function getUrlPrefix(spaceId: string) { + return spaceId && spaceId !== 'default' ? `/s/${spaceId}` : ``; } diff --git a/x-pack/test/alerting_api_integration/services.ts b/x-pack/test/alerting_api_integration/common/services.ts similarity index 80% rename from x-pack/test/alerting_api_integration/services.ts rename to x-pack/test/alerting_api_integration/common/services.ts index b7398349cce5da..a927a31469bab1 100644 --- a/x-pack/test/alerting_api_integration/services.ts +++ b/x-pack/test/alerting_api_integration/common/services.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { services } from '../api_integration/services'; +export { services } from '../../api_integration/services'; diff --git a/x-pack/test/alerting_api_integration/common/types.ts b/x-pack/test/alerting_api_integration/common/types.ts new file mode 100644 index 00000000000000..e94add5bbcd288 --- /dev/null +++ b/x-pack/test/alerting_api_integration/common/types.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +interface FeaturesPrivileges { + [featureId: string]: string[]; +} + +// TODO: Consolidate the following type definitions +interface CustomRoleSpecificationElasticsearchIndices { + names: string[]; + privileges: string[]; +} + +export interface RoleKibanaPrivilege { + spaces: string[]; + base?: string[]; + feature?: FeaturesPrivileges; +} + +export interface CustomRoleSpecification { + name: string; + elasticsearch?: { + cluster?: string[]; + indices?: CustomRoleSpecificationElasticsearchIndices[]; + }; + kibana?: RoleKibanaPrivilege[]; +} + +interface ReservedRoleSpecification { + name: string; +} + +export function isCustomRoleSpecification( + roleSpecification: CustomRoleSpecification | ReservedRoleSpecification +): roleSpecification is CustomRoleSpecification { + const customRoleDefinition = roleSpecification as CustomRoleSpecification; + return ( + customRoleDefinition.kibana !== undefined || customRoleDefinition.elasticsearch !== undefined + ); +} + +export interface User { + username: string; + fullName: string; + password: string; + role?: ReservedRoleSpecification | CustomRoleSpecification; + roles?: Array; +} + +export interface Space { + id: string; + name: string; + disabledFeatures: string[]; +} diff --git a/x-pack/test/alerting_api_integration/config_security_enabled.js b/x-pack/test/alerting_api_integration/config_security_enabled.js deleted file mode 100644 index ffbf0b558853e4..00000000000000 --- a/x-pack/test/alerting_api_integration/config_security_enabled.js +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { join, resolve } from 'path'; -import { CA_CERT_PATH } from '@kbn/dev-utils'; -import { services } from './services'; -import { SLACK_ACTION_SIMULATOR_URI } from './fixtures/plugins/actions'; - -export async function getApiIntegrationConfig({ readConfigFile }) { - const xPackApiIntegrationTestsConfig = await readConfigFile(require.resolve('../api_integration/config.js')); - - const servers = { - ...xPackApiIntegrationTestsConfig.get('servers'), - elasticsearch: { - ...xPackApiIntegrationTestsConfig.get('servers.elasticsearch'), - protocol: 'https', - }, - }; - - return { - testFiles: [require.resolve('./apis')], - services, - servers, - esArchiver: { - directory: resolve(__dirname, 'es_archives'), - }, - junit: { - reportName: 'X-Pack Alerting API Integration Tests', - }, - kbnTestServer: { - ...xPackApiIntegrationTestsConfig.get('kbnTestServer'), - serverArgs: [ - ...xPackApiIntegrationTestsConfig.get('kbnTestServer.serverArgs'), - `--plugin-path=${join(__dirname, 'fixtures', 'plugins', 'alerts')}`, - `--plugin-path=${join(__dirname, 'fixtures', 'plugins', 'actions')}`, - `--server.xsrf.whitelist=${JSON.stringify([SLACK_ACTION_SIMULATOR_URI])}`, - `--elasticsearch.hosts=${servers.elasticsearch.protocol}://${servers.elasticsearch.hostname}:${servers.elasticsearch.port}`, - `--elasticsearch.ssl.certificateAuthorities=${CA_CERT_PATH}`, - ], - }, - esTestCluster: { - ...xPackApiIntegrationTestsConfig.get('esTestCluster'), - ssl: true, - }, - }; -} - -export default getApiIntegrationConfig; diff --git a/x-pack/test/alerting_api_integration/es_archives/actions/README.md b/x-pack/test/alerting_api_integration/es_archives/actions/README.md deleted file mode 100644 index e833a429dce070..00000000000000 --- a/x-pack/test/alerting_api_integration/es_archives/actions/README.md +++ /dev/null @@ -1,24 +0,0 @@ -The values of `id` and `secrets` in the `basic/data.json` file -may change over time, and to get the current "correct" value to replace it with, -you can do the following: - - -- add a `process.exit()` in this test, after an action is created: - - https://github.com/elastic/kibana/blob/master/x-pack/test/api_integration/apis/actions/create.ts#L37 - -- figure out what data got put in ES via - - curl -v 'http://elastic:changeme@localhost:9220/_search?q=type:action' | json - -- there should be a new `id` and `secrets` - -- update the following files: - - - `id` and `secrets` - - `x-pack/test/functional/es_archives/actions/basic/data.json` - - - `id` - - `x-pack/test/api_integration/apis/actions/constants.ts` diff --git a/x-pack/test/alerting_api_integration/es_archives/actions/basic/data.json b/x-pack/test/alerting_api_integration/es_archives/actions/basic/data.json deleted file mode 100644 index b8dc9e307ee9e2..00000000000000 --- a/x-pack/test/alerting_api_integration/es_archives/actions/basic/data.json +++ /dev/null @@ -1,69 +0,0 @@ -{ - "value": { - "id": "action:60071910-1ef1-4a72-9a68-b94af6a836a7", - "index": ".kibana", - "source": { - "type": "action", - "migrationVersion": {}, - "action": { - "description": "My action", - "actionTypeId": "test.index-record", - "config": { - "unencrypted" : "This value shouldn't get encrypted" - }, - "secrets": "P1k6WTkcODQcb9+xWwO9s9qrCTimK2pMJDOtN0gJsraa0zx5dRroZ2Ppsr6D5ffPSN5QpO0aPVvef7/TLTw3A2Fn7Q44hrz+nEqZ+NBQ2zmQFq+8208DrhNu/Z+4M175UW+u8mUX3tkGqGU/rcbw9HGHK8NcpZC6Fgx5wCmUSfQWklNN2FJXjyAG" - } - } - } -} - -{ - "value": { - "id": "action:08cca6da-60ed-49ca-85f6-641240300a3f", - "index": ".kibana", - "source": { - "type": "action", - "migrationVersion": {}, - "action": { - "description": "My failing action", - "actionTypeId": "test.failing", - "config": {} - } - } - } -} - -{ - "value": { - "id": "action:ce37997f-0fb6-460a-8baf-f81ac5d38348", - "index": ".kibana", - "source": { - "type": "action", - "migrationVersion": {}, - "action": { - "description": "My rate limited action", - "actionTypeId": "test.rate-limit", - "config": {} - } - } - } -} - -{ - "value": { - "id" : "space_1:action:6c7d0f6b-2fb5-4821-b182-624fc3ccc7a3", - "index" : ".kibana", - "source" : { - "action" : { - "description" : "My action", - "actionTypeId" : "test.index-record", - "config" : { - "unencrypted" : "This value shouldn't get encrypted" - }, - "secrets" : "LEepwqGVUSkkAZSYCGDz3Y0DGoRgBGPRnu+Ta0jmEz+TiGUj3SBYWL6t2jqncBhsWgzSzwCusY+z5B/k+4wjaXW5t/KxNZP7bGpLQK0hL9IwKxqmRzRbEvX0nzeExxgfSaMRjjn2SnrE21MTw6qyBwGNqnuvmN7ILde4ZUbR9Jyjl8A6Y0GDcWvN" - }, - "type": "action", - "namespace": "space_1" - } - } -} diff --git a/x-pack/test/alerting_api_integration/es_archives/actions/basic/mappings.json b/x-pack/test/alerting_api_integration/es_archives/actions/basic/mappings.json deleted file mode 100644 index d9b4918add58f1..00000000000000 --- a/x-pack/test/alerting_api_integration/es_archives/actions/basic/mappings.json +++ /dev/null @@ -1,100 +0,0 @@ -{ - "type": "index", - "value": { - "index": ".kibana", - "settings": { - "index": { - "number_of_shards": "1", - "auto_expand_replicas": "0-1", - "number_of_replicas": "0" - } - }, - "mappings": { - "doc": { - "dynamic": "strict", - "properties": { - "spaceId": { - "type": "keyword" - }, - "namespace": { - "type": "keyword" - }, - "type": { - "type": "keyword" - }, - "updated_at": { - "type": "date" - }, - "migrationVersion": { - "dynamic": "true", - "properties": { - "index-pattern": { - "type": "text", - "fields": { - "keyword": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "space": { - "type": "text", - "fields": { - "keyword": { - "type": "keyword", - "ignore_above": 256 - } - } - } - } - }, - "action": { - "properties": { - "description": { - "type": "text" - }, - "actionTypeId": { - "type": "keyword" - }, - "config": { - "enabled": "false", - "type": "object" - }, - "secrets": { - "type": "binary" - } - } - }, - "space": { - "properties": { - "_reserved": { - "type": "boolean" - }, - "color": { - "type": "keyword" - }, - "description": { - "type": "text" - }, - "disabledFeatures": { - "type": "keyword" - }, - "initials": { - "type": "keyword" - }, - "name": { - "type": "text", - "fields": { - "keyword": { - "type": "keyword", - "ignore_above": 2048 - } - } - } - } - } - } - } - } - } -} diff --git a/x-pack/test/alerting_api_integration/es_archives/empty_kibana/data.json.gz b/x-pack/test/alerting_api_integration/es_archives/empty_kibana/data.json.gz deleted file mode 100644 index 8334749a696d7d..00000000000000 Binary files a/x-pack/test/alerting_api_integration/es_archives/empty_kibana/data.json.gz and /dev/null differ diff --git a/x-pack/test/alerting_api_integration/es_archives/empty_kibana/mappings.json b/x-pack/test/alerting_api_integration/es_archives/empty_kibana/mappings.json deleted file mode 100644 index 77eac534850a55..00000000000000 --- a/x-pack/test/alerting_api_integration/es_archives/empty_kibana/mappings.json +++ /dev/null @@ -1,284 +0,0 @@ -{ - "type": "index", - "value": { - "index": ".kibana", - "mappings": { - "properties": { - "config": { - "dynamic": "true", - "properties": { - "buildNum": { - "type": "keyword" - }, - "dateFormat:tz": { - "fields": { - "keyword": { - "ignore_above": 256, - "type": "keyword" - } - }, - "type": "text" - } - } - }, - "dashboard": { - "dynamic": "strict", - "properties": { - "description": { - "type": "text" - }, - "hits": { - "type": "integer" - }, - "kibanaSavedObjectMeta": { - "properties": { - "searchSourceJSON": { - "type": "text" - } - } - }, - "optionsJSON": { - "type": "text" - }, - "panelsJSON": { - "type": "text" - }, - "refreshInterval": { - "properties": { - "display": { - "type": "keyword" - }, - "pause": { - "type": "boolean" - }, - "section": { - "type": "integer" - }, - "value": { - "type": "integer" - } - } - }, - "timeFrom": { - "type": "keyword" - }, - "timeRestore": { - "type": "boolean" - }, - "timeTo": { - "type": "keyword" - }, - "title": { - "type": "text" - }, - "uiStateJSON": { - "type": "text" - }, - "version": { - "type": "integer" - } - } - }, - "index-pattern": { - "dynamic": "strict", - "properties": { - "fieldFormatMap": { - "type": "text" - }, - "fields": { - "type": "text" - }, - "intervalName": { - "type": "keyword" - }, - "notExpandable": { - "type": "boolean" - }, - "sourceFilters": { - "type": "text" - }, - "timeFieldName": { - "type": "keyword" - }, - "title": { - "type": "text" - } - } - }, - "search": { - "dynamic": "strict", - "properties": { - "columns": { - "type": "keyword" - }, - "description": { - "type": "text" - }, - "hits": { - "type": "integer" - }, - "kibanaSavedObjectMeta": { - "properties": { - "searchSourceJSON": { - "type": "text" - } - } - }, - "sort": { - "type": "keyword" - }, - "title": { - "type": "text" - }, - "version": { - "type": "integer" - } - } - }, - "server": { - "dynamic": "strict", - "properties": { - "uuid": { - "type": "keyword" - } - } - }, - "space": { - "properties": { - "_reserved": { - "type": "boolean" - }, - "color": { - "type": "keyword" - }, - "description": { - "type": "text" - }, - "disabledFeatures": { - "type": "keyword" - }, - "initials": { - "type": "keyword" - }, - "name": { - "fields": { - "keyword": { - "ignore_above": 2048, - "type": "keyword" - } - }, - "type": "text" - } - } - }, - "spaceId": { - "type": "keyword" - }, - "timelion-sheet": { - "dynamic": "strict", - "properties": { - "description": { - "type": "text" - }, - "hits": { - "type": "integer" - }, - "kibanaSavedObjectMeta": { - "properties": { - "searchSourceJSON": { - "type": "text" - } - } - }, - "timelion_chart_height": { - "type": "integer" - }, - "timelion_columns": { - "type": "integer" - }, - "timelion_interval": { - "type": "keyword" - }, - "timelion_other_interval": { - "type": "keyword" - }, - "timelion_rows": { - "type": "integer" - }, - "timelion_sheet": { - "type": "text" - }, - "title": { - "type": "text" - }, - "version": { - "type": "integer" - } - } - }, - "type": { - "type": "keyword" - }, - "url": { - "dynamic": "strict", - "properties": { - "accessCount": { - "type": "long" - }, - "accessDate": { - "type": "date" - }, - "createDate": { - "type": "date" - }, - "url": { - "fields": { - "keyword": { - "ignore_above": 2048, - "type": "keyword" - } - }, - "type": "text" - } - } - }, - "visualization": { - "dynamic": "strict", - "properties": { - "description": { - "type": "text" - }, - "kibanaSavedObjectMeta": { - "properties": { - "searchSourceJSON": { - "type": "text" - } - } - }, - "savedSearchId": { - "type": "keyword" - }, - "title": { - "type": "text" - }, - "uiStateJSON": { - "type": "text" - }, - "version": { - "type": "integer" - }, - "visState": { - "type": "text" - } - } - } - } - }, - "settings": { - "index": { - "number_of_replicas": "1", - "number_of_shards": "1" - } - } - } -} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/config.ts b/x-pack/test/alerting_api_integration/security_and_spaces/config.ts new file mode 100644 index 00000000000000..081b901c47fc3c --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/config.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { createTestConfig } from '../common/config'; + +// eslint-disable-next-line import/no-default-export +export default createTestConfig('security_and_spaces', { + disabledPlugins: [], + license: 'trial', + ssl: true, +}); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/scenarios.ts b/x-pack/test/alerting_api_integration/security_and_spaces/scenarios.ts new file mode 100644 index 00000000000000..0ca3c19c4962c7 --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/scenarios.ts @@ -0,0 +1,178 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { Space, User } from '../common/types'; + +const NoKibanaPrivileges: User = { + username: 'no_kibana_privileges', + fullName: 'no_kibana_privileges', + password: 'no_kibana_privileges-password', + role: { + name: 'no_kibana_privileges', + elasticsearch: { + // TODO: Remove once Elasticsearch doesn't require the permission for own keys + cluster: ['manage_api_key'], + indices: [ + { + names: ['foo'], + privileges: ['all'], + }, + { + names: ['.kibaka-alerting-test-data*'], + privileges: ['all'], + }, + ], + }, + }, +}; + +const Superuser: User = { + username: 'superuser', + fullName: 'superuser', + password: 'superuser-password', + role: { + name: 'superuser', + }, +}; + +const GlobalRead: User = { + username: 'global_read', + fullName: 'global_read', + password: 'global_read-password', + role: { + name: 'global_read_role', + kibana: [ + { + feature: { + alerting: ['read'], + actions: ['read'], + }, + spaces: ['*'], + }, + ], + elasticsearch: { + // TODO: Remove once Elasticsearch doesn't require the permission for own keys + cluster: ['manage_api_key'], + indices: [ + { + names: ['.kibaka-alerting-test-data*'], + privileges: ['all'], + }, + ], + }, + }, +}; + +const Space1All: User = { + username: 'space_1_all', + fullName: 'space_1_all', + password: 'space_1_all-password', + role: { + name: 'space_1_all_role', + kibana: [ + { + feature: { + alerting: ['all'], + actions: ['all'], + }, + spaces: ['space1'], + }, + ], + elasticsearch: { + // TODO: Remove once Elasticsearch doesn't require the permission for own keys + cluster: ['manage_api_key'], + indices: [ + { + names: ['.kibaka-alerting-test-data*'], + privileges: ['all'], + }, + ], + }, + }, +}; + +export const Users: User[] = [NoKibanaPrivileges, Superuser, GlobalRead, Space1All]; + +const Space1: Space = { + id: 'space1', + name: 'Space 1', + disabledFeatures: [], +}; + +const Space2: Space = { + id: 'space2', + name: 'Space 2', + disabledFeatures: [], +}; + +export const Spaces: Space[] = [Space1, Space2]; + +// For all scenarios, we define both an instance in addition +// to a "type" definition so that we can use the exhaustive switch in +// typescript to ensure all scenarios are handled. + +interface Scenario { + user: User; + space: Space; +} + +interface NoKibanaPrivilegesAtSpace1 extends Scenario { + id: 'no_kibana_privileges at space1'; +} +const NoKibanaPrivilegesAtSpace1: NoKibanaPrivilegesAtSpace1 = { + id: 'no_kibana_privileges at space1', + user: NoKibanaPrivileges, + space: Space1, +}; + +interface SuperuserAtSpace1 extends Scenario { + id: 'superuser at space1'; +} +const SuperuserAtSpace1: SuperuserAtSpace1 = { + id: 'superuser at space1', + user: Superuser, + space: Space1, +}; + +interface GlobalReadAtSpace1 extends Scenario { + id: 'global_read at space1'; +} +const GlobalReadAtSpace1: GlobalReadAtSpace1 = { + id: 'global_read at space1', + user: GlobalRead, + space: Space1, +}; + +interface Space1AllAtSpace1 extends Scenario { + id: 'space_1_all at space1'; +} +const Space1AllAtSpace1: Space1AllAtSpace1 = { + id: 'space_1_all at space1', + user: Space1All, + space: Space1, +}; + +interface Space1AllAtSpace2 extends Scenario { + id: 'space_1_all at space2'; +} +const Space1AllAtSpace2: Space1AllAtSpace2 = { + id: 'space_1_all at space2', + user: Space1All, + space: Space2, +}; + +export const UserAtSpaceScenarios: [ + NoKibanaPrivilegesAtSpace1, + SuperuserAtSpace1, + GlobalReadAtSpace1, + Space1AllAtSpace1, + Space1AllAtSpace2 +] = [ + NoKibanaPrivilegesAtSpace1, + SuperuserAtSpace1, + GlobalReadAtSpace1, + Space1AllAtSpace1, + Space1AllAtSpace2, +]; diff --git a/x-pack/test/alerting_api_integration/apis/actions/builtin_action_types/email.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/email.ts similarity index 97% rename from x-pack/test/alerting_api_integration/apis/actions/builtin_action_types/email.ts rename to x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/email.ts index b253fbdc7f8452..659a73394519a0 100644 --- a/x-pack/test/alerting_api_integration/apis/actions/builtin_action_types/email.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/email.ts @@ -6,8 +6,9 @@ import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; +// eslint-disable-next-line import/no-default-export export default function emailTest({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const esArchiver = getService('esArchiver'); diff --git a/x-pack/test/alerting_api_integration/apis/actions/builtin_action_types/es_index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/es_index.ts similarity index 98% rename from x-pack/test/alerting_api_integration/apis/actions/builtin_action_types/es_index.ts rename to x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/es_index.ts index 6e94b69b2cd808..b9b40be3a04b3e 100644 --- a/x-pack/test/alerting_api_integration/apis/actions/builtin_action_types/es_index.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/es_index.ts @@ -6,10 +6,11 @@ import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; const ES_TEST_INDEX_NAME = 'functional-test-actions-index'; +// eslint-disable-next-line import/no-default-export export default function indexTest({ getService }: FtrProviderContext) { const es = getService('es'); const supertest = getService('supertest'); diff --git a/x-pack/test/alerting_api_integration/apis/actions/builtin_action_types/server_log.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/server_log.ts similarity index 93% rename from x-pack/test/alerting_api_integration/apis/actions/builtin_action_types/server_log.ts rename to x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/server_log.ts index 926a8e887434e3..cefdc1fc15f70d 100644 --- a/x-pack/test/alerting_api_integration/apis/actions/builtin_action_types/server_log.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/server_log.ts @@ -6,8 +6,9 @@ import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; +// eslint-disable-next-line import/no-default-export export default function serverLogTest({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const esArchiver = getService('esArchiver'); diff --git a/x-pack/test/alerting_api_integration/apis/actions/builtin_action_types/slack.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/slack.ts similarity index 95% rename from x-pack/test/alerting_api_integration/apis/actions/builtin_action_types/slack.ts rename to x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/slack.ts index c09667ff5670d1..211bd5d049f211 100644 --- a/x-pack/test/alerting_api_integration/apis/actions/builtin_action_types/slack.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/slack.ts @@ -6,10 +6,11 @@ import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; -import { SLACK_ACTION_SIMULATOR_URI } from '../../../fixtures/plugins/actions'; +import { SLACK_ACTION_SIMULATOR_URI } from '../../../../common/fixtures/plugins/actions'; +// eslint-disable-next-line import/no-default-export export default function slackTest({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const esArchiver = getService('esArchiver'); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/create.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/create.ts new file mode 100644 index 00000000000000..e044d6a1fd2b07 --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/create.ts @@ -0,0 +1,185 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { UserAtSpaceScenarios } from '../../scenarios'; +import { getUrlPrefix, ObjectRemover } from '../../../common/lib'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function createActionTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + describe('create', () => { + const objectRemover = new ObjectRemover(supertest); + + after(() => objectRemover.removeAll()); + + for (const scenario of UserAtSpaceScenarios) { + const { user, space } = scenario; + describe(scenario.id, () => { + it('should handle create action request appropriately', async () => { + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(space.id)}/api/action`) + .auth(user.username, user.password) + .set('kbn-xsrf', 'foo') + .send({ + description: 'My action', + actionTypeId: 'test.index-record', + config: { + unencrypted: `This value shouldn't get encrypted`, + }, + 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 at space2': + expect(response.statusCode).to.eql(404); + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Not Found', + }); + break; + case 'superuser at space1': + case 'space_1_all at space1': + expect(response.statusCode).to.eql(200); + expect(response.body).to.eql({ + id: response.body.id, + description: 'My action', + actionTypeId: 'test.index-record', + config: { + unencrypted: `This value shouldn't get encrypted`, + }, + }); + expect(typeof response.body.id).to.be('string'); + objectRemover.add(space.id, response.body.id, 'action'); + 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/action`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .send({ + description: 'My action', + actionTypeId: 'test.unregistered-action-type', + config: {}, + }); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'global_read at space1': + case 'space_1_all at space2': + expect(response.statusCode).to.eql(404); + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Not Found', + }); + break; + case 'superuser at space1': + case 'space_1_all at space1': + expect(response.statusCode).to.eql(400); + expect(response.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: 'Action type "test.unregistered-action-type" is not registered.', + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle create action request appropriately when payload is empty and invalid', async () => { + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(space.id)}/api/action`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .send({}); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'global_read at space1': + case 'space_1_all at space2': + expect(response.statusCode).to.eql(404); + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Not Found', + }); + break; + case 'superuser at space1': + case 'space_1_all at space1': + expect(response.statusCode).to.eql(400); + expect(response.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'child "description" fails because ["description" is required]. child "actionTypeId" fails because ["actionTypeId" is required]', + validation: { + source: 'payload', + keys: ['description', 'actionTypeId'], + }, + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it(`should handle create action request appropriately when config isn't valid`, async () => { + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(space.id)}/api/action`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .send({ + description: 'my description', + actionTypeId: 'test.index-record', + config: { + unencrypted: 'my unencrypted text', + }, + }); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'global_read at space1': + case 'space_1_all at space2': + expect(response.statusCode).to.eql(404); + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Not Found', + }); + break; + case 'superuser at space1': + case 'space_1_all at space1': + expect(response.statusCode).to.eql(400); + expect(response.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type secrets: [encrypted]: expected value of type [string] but got [undefined]', + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + }); + } + }); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/delete.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/delete.ts new file mode 100644 index 00000000000000..d885f065a8898e --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/delete.ts @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +import { UserAtSpaceScenarios } from '../../scenarios'; +import { getUrlPrefix, ObjectRemover } from '../../../common/lib'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function deleteActionTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + describe('delete', () => { + const objectRemover = new ObjectRemover(supertest); + + after(() => objectRemover.removeAll()); + + for (const scenario of UserAtSpaceScenarios) { + const { user, space } = scenario; + describe(scenario.id, () => { + it('should handle delete action request appropriately', async () => { + const { body: createdAction } = await supertest + .post(`${getUrlPrefix(space.id)}/api/action`) + .set('kbn-xsrf', 'foo') + .send({ + description: 'My action', + actionTypeId: 'test.index-record', + config: { + unencrypted: `This value shouldn't get encrypted`, + }, + secrets: { + encrypted: 'This value should be encrypted', + }, + }) + .expect(200); + + const response = await supertestWithoutAuth + .delete(`${getUrlPrefix(space.id)}/api/action/${createdAction.id}`) + .auth(user.username, user.password) + .set('kbn-xsrf', 'foo'); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'global_read at space1': + case 'space_1_all at space2': + expect(response.statusCode).to.eql(404); + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Not Found', + }); + objectRemover.add(space.id, createdAction.id, 'action'); + break; + case 'superuser at space1': + case 'space_1_all at space1': + expect(response.statusCode).to.eql(204); + expect(response.body).to.eql(''); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it(`should handle delete request appropriately when action doesn't exist`, async () => { + const response = await supertestWithoutAuth + .delete(`${getUrlPrefix(space.id)}/api/action/2`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'global_read at space1': + case 'space_1_all at space2': + expect(response.statusCode).to.eql(404); + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Not Found', + }); + break; + case 'superuser at space1': + case 'space_1_all at space1': + expect(response.statusCode).to.eql(404); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + }); + } + }); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/execute.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/execute.ts new file mode 100644 index 00000000000000..b9749ed5e8108c --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/execute.ts @@ -0,0 +1,456 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { UserAtSpaceScenarios } from '../../scenarios'; +import { getUrlPrefix, ObjectRemover } from '../../../common/lib'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const es = getService('es'); + + const esTestIndexName = '.kibaka-alerting-test-data'; + const authorizationIndex = '.kibana-test-authorization'; + + describe('execute', () => { + const objectRemover = new ObjectRemover(supertest); + + before(async () => { + await es.indices.delete({ index: esTestIndexName, ignore: [404] }); + await es.indices.create({ + index: esTestIndexName, + body: { + mappings: { + properties: { + source: { + type: 'keyword', + }, + reference: { + type: 'keyword', + }, + params: { + enabled: false, + type: 'object', + }, + config: { + enabled: false, + type: 'object', + }, + state: { + enabled: false, + type: 'object', + }, + }, + }, + }, + }); + await es.indices.create({ index: authorizationIndex }); + }); + after(async () => { + await es.indices.delete({ index: esTestIndexName }); + await es.indices.delete({ index: authorizationIndex }); + await objectRemover.removeAll(); + }); + + async function getTestIndexDoc(source: string, reference: string) { + const searchResult = await es.search({ + index: esTestIndexName, + body: { + query: { + bool: { + must: [ + { + term: { + source, + }, + }, + { + term: { + reference, + }, + }, + ], + }, + }, + }, + }); + expect(searchResult.hits.total.value).to.eql(1); + return searchResult.hits.hits[0]; + } + + for (const scenario of UserAtSpaceScenarios) { + const { user, space } = scenario; + describe(scenario.id, () => { + it('should handle execute request appropriately', async () => { + const { body: createdAction } = await supertest + .post(`${getUrlPrefix(space.id)}/api/action`) + .set('kbn-xsrf', 'foo') + .send({ + description: 'My action', + actionTypeId: 'test.index-record', + config: { + unencrypted: `This value shouldn't get encrypted`, + }, + secrets: { + encrypted: 'This value should be encrypted', + }, + }) + .expect(200); + objectRemover.add(space.id, createdAction.id, 'action'); + + const reference = `actions-execute-1:${user.username}`; + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(space.id)}/api/action/${createdAction.id}/_execute`) + .auth(user.username, user.password) + .set('kbn-xsrf', 'foo') + .send({ + params: { + reference, + index: esTestIndexName, + message: 'Testing 123', + }, + }); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + expect(response.statusCode).to.eql(404); + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Not Found', + }); + break; + case 'global_read at space1': + case 'superuser at space1': + case 'space_1_all at space1': + expect(response.statusCode).to.eql(200); + expect(response.body).to.be.an('object'); + const indexedRecord = await getTestIndexDoc('action:test.index-record', reference); + expect(indexedRecord._source).to.eql({ + params: { + reference, + index: esTestIndexName, + message: 'Testing 123', + }, + config: { + unencrypted: `This value shouldn't get encrypted`, + }, + secrets: { + encrypted: 'This value should be encrypted', + }, + reference, + source: 'action:test.index-record', + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle execute request appropriately after action is updated', async () => { + const { body: createdAction } = await supertest + .post(`${getUrlPrefix(space.id)}/api/action`) + .set('kbn-xsrf', 'foo') + .send({ + description: 'My action', + actionTypeId: 'test.index-record', + config: { + unencrypted: `This value shouldn't get encrypted`, + }, + secrets: { + encrypted: 'This value should be encrypted', + }, + }) + .expect(200); + objectRemover.add(space.id, createdAction.id, 'action'); + + await supertest + .put(`${getUrlPrefix(space.id)}/api/action/${createdAction.id}`) + .set('kbn-xsrf', 'foo') + .send({ + description: 'My action updated', + config: { + unencrypted: `This value shouldn't get encrypted`, + }, + secrets: { + encrypted: 'This value should be encrypted', + }, + }) + .expect(200); + + const reference = `actions-execute-2:${user.username}`; + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(space.id)}/api/action/${createdAction.id}/_execute`) + .auth(user.username, user.password) + .set('kbn-xsrf', 'foo') + .send({ + params: { + reference, + index: esTestIndexName, + message: 'Testing 123', + }, + }); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + expect(response.statusCode).to.eql(404); + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Not Found', + }); + break; + case 'global_read at space1': + case 'superuser at space1': + case 'space_1_all at space1': + expect(response.statusCode).to.eql(200); + expect(response.body).to.be.an('object'); + const indexedRecord = await getTestIndexDoc('action:test.index-record', reference); + expect(indexedRecord._source).to.eql({ + params: { + reference, + index: esTestIndexName, + message: 'Testing 123', + }, + config: { + unencrypted: `This value shouldn't get encrypted`, + }, + secrets: { + encrypted: 'This value should be encrypted', + }, + reference, + source: 'action:test.index-record', + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it(`should handle execute request appropriately when action doesn't exist`, async () => { + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(space.id)}/api/action/1/_execute`) + .auth(user.username, user.password) + .set('kbn-xsrf', 'foo') + .send({ + params: { foo: true }, + }); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + expect(response.statusCode).to.eql(404); + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Not Found', + }); + break; + case 'global_read at space1': + case 'superuser at space1': + case 'space_1_all at space1': + expect(response.statusCode).to.eql(404); + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Saved object [action/1] not found', + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle execute request appropriately when payload is empty and invalid', async () => { + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(space.id)}/api/action/1/_execute`) + .auth(user.username, user.password) + .set('kbn-xsrf', 'foo') + .send({}); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + expect(response.statusCode).to.eql(404); + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Not Found', + }); + break; + case 'global_read at space1': + case 'superuser at space1': + case 'space_1_all at space1': + expect(response.statusCode).to.eql(400); + expect(response.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: 'child "params" fails because ["params" is required]', + validation: { + source: 'payload', + keys: ['params'], + }, + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle execute request appropriately after changing config properties', async () => { + const { body: createdAction } = await supertest + .post(`${getUrlPrefix(space.id)}/api/action`) + .set('kbn-xsrf', 'foo') + .send({ + description: 'test email action', + actionTypeId: '.email', + config: { + from: 'email-from@example.com', + host: 'host-is-ignored-here.example.com', + port: 666, + }, + secrets: { + user: 'email-user', + password: 'email-password', + }, + }) + .expect(200); + objectRemover.add(space.id, createdAction.id, 'action'); + + await supertest + .put(`${getUrlPrefix(space.id)}/api/action/${createdAction.id}`) + .set('kbn-xsrf', 'foo') + .send({ + description: 'a test email action 2', + config: { + from: 'email-from@example.com', + service: '__json', + }, + secrets: { + user: 'email-user', + password: 'email-password', + }, + }) + .expect(200); + + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(space.id)}/api/action/${createdAction.id}/_execute`) + .auth(user.username, user.password) + .set('kbn-xsrf', 'foo') + .send({ + params: { + to: ['X'], + subject: 'email-subject', + message: 'email-message', + }, + }); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + expect(response.statusCode).to.eql(404); + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Not Found', + }); + break; + case 'global_read at space1': + case 'superuser at space1': + case 'space_1_all at space1': + expect(response.statusCode).to.eql(200); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle execute request appropriately and have proper callCluster and savedObjectsClient authorization', async () => { + let indexedRecord: any; + const reference = `actions-execute-3:${user.username}`; + const { body: createdAction } = await supertest + .post(`${getUrlPrefix(space.id)}/api/action`) + .set('kbn-xsrf', 'foo') + .send({ + description: 'My action', + actionTypeId: 'test.authorization', + }) + .expect(200); + objectRemover.add(space.id, createdAction.id, 'action'); + + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(space.id)}/api/action/${createdAction.id}/_execute`) + .auth(user.username, user.password) + .set('kbn-xsrf', 'foo') + .send({ + params: { + callClusterAuthorizationIndex: authorizationIndex, + savedObjectsClientType: 'dashboard', + savedObjectsClientId: '1', + index: esTestIndexName, + reference, + }, + }); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + expect(response.statusCode).to.eql(404); + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Not Found', + }); + break; + case 'global_read at space1': + case 'space_1_all at space1': + expect(response.statusCode).to.eql(200); + indexedRecord = await getTestIndexDoc('action:test.authorization', reference); + expect(indexedRecord._source.state).to.eql({ + callClusterSuccess: false, + savedObjectsClientSuccess: false, + callClusterError: { + ...indexedRecord._source.state.callClusterError, + msg: `[security_exception] action [indices:data/write/bulk[s]] is unauthorized for user [${user.username}]`, + statusCode: 403, + }, + savedObjectsClientError: { + ...indexedRecord._source.state.savedObjectsClientError, + output: { + ...indexedRecord._source.state.savedObjectsClientError.output, + statusCode: 403, + }, + }, + }); + break; + case 'superuser at space1': + expect(response.statusCode).to.eql(200); + indexedRecord = await getTestIndexDoc('action:test.authorization', reference); + expect(indexedRecord._source.state).to.eql({ + callClusterSuccess: true, + savedObjectsClientSuccess: false, + savedObjectsClientError: { + ...indexedRecord._source.state.savedObjectsClientError, + output: { + ...indexedRecord._source.state.savedObjectsClientError.output, + statusCode: 404, + }, + }, + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + }); + } + }); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/find.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/find.ts new file mode 100644 index 00000000000000..c73d6110e36d6f --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/find.ts @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { UserAtSpaceScenarios } from '../../scenarios'; +import { getUrlPrefix, ObjectRemover } from '../../../common/lib'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function findActionTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + describe('find', () => { + const objectRemover = new ObjectRemover(supertest); + + afterEach(() => objectRemover.removeAll()); + + for (const scenario of UserAtSpaceScenarios) { + const { user, space } = scenario; + describe(scenario.id, () => { + it('should handle find action request appropriately', async () => { + const { body: createdAction } = await supertest + .post(`${getUrlPrefix(space.id)}/api/action`) + .set('kbn-xsrf', 'foo') + .send({ + description: 'My action', + actionTypeId: 'test.index-record', + config: { + unencrypted: `This value shouldn't get encrypted`, + }, + secrets: { + encrypted: 'This value should be encrypted', + }, + }) + .expect(200); + objectRemover.add(space.id, createdAction.id, 'action'); + + const response = await supertestWithoutAuth + .get( + `${getUrlPrefix( + space.id + )}/api/action/_find?search=test.index-record&search_fields=actionTypeId` + ) + .auth(user.username, user.password); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + expect(response.statusCode).to.eql(404); + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Not Found', + }); + break; + case 'global_read at space1': + case 'superuser at space1': + case 'space_1_all at space1': + expect(response.statusCode).to.eql(200); + expect(response.body).to.eql({ + page: 1, + perPage: 20, + total: 1, + data: [ + { + id: createdAction.id, + description: 'My action', + actionTypeId: 'test.index-record', + config: { + unencrypted: `This value shouldn't get encrypted`, + }, + }, + ], + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + }); + } + }); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/get.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/get.ts new file mode 100644 index 00000000000000..259eb3301842cd --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/get.ts @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { UserAtSpaceScenarios } from '../../scenarios'; +import { getUrlPrefix, ObjectRemover } from '../../../common/lib'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function getActionTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + describe('get', () => { + const objectRemover = new ObjectRemover(supertest); + + afterEach(() => objectRemover.removeAll()); + + for (const scenario of UserAtSpaceScenarios) { + const { user, space } = scenario; + describe(scenario.id, () => { + it('should handle get action request appropriately', async () => { + const { body: createdAction } = await supertest + .post(`${getUrlPrefix(space.id)}/api/action`) + .set('kbn-xsrf', 'foo') + .send({ + description: 'My action', + actionTypeId: 'test.index-record', + config: { + unencrypted: `This value shouldn't get encrypted`, + }, + secrets: { + encrypted: 'This value should be encrypted', + }, + }) + .expect(200); + objectRemover.add(space.id, createdAction.id, 'action'); + + const response = await supertestWithoutAuth + .get(`${getUrlPrefix(space.id)}/api/action/${createdAction.id}`) + .auth(user.username, user.password); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + expect(response.statusCode).to.eql(404); + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Not Found', + }); + break; + case 'global_read at space1': + case 'superuser at space1': + case 'space_1_all at space1': + expect(response.statusCode).to.eql(200); + expect(response.body).to.eql({ + id: createdAction.id, + actionTypeId: 'test.index-record', + description: 'My action', + config: { + unencrypted: `This value shouldn't get encrypted`, + }, + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + }); + } + }); +} diff --git a/x-pack/test/alerting_api_integration/apis/actions/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts similarity index 88% rename from x-pack/test/alerting_api_integration/apis/actions/index.ts rename to x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts index 66e83563f802a0..0e830239ca74ca 100644 --- a/x-pack/test/alerting_api_integration/apis/actions/index.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts @@ -4,8 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { FtrProviderContext } from '../../ftr_provider_context'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +// eslint-disable-next-line import/no-default-export export default function actionsTests({ loadTestFile }: FtrProviderContext) { describe('Actions', () => { loadTestFile(require.resolve('./create')); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/list_action_types.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/list_action_types.ts new file mode 100644 index 00000000000000..f70e8f561b23e8 --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/list_action_types.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { UserAtSpaceScenarios } from '../../scenarios'; +import { getUrlPrefix } from '../../../common/lib/space_test_utils'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function listActionTypesTests({ getService }: FtrProviderContext) { + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + describe('list_action_types', () => { + for (const scenario of UserAtSpaceScenarios) { + const { user, space } = scenario; + describe(scenario.id, () => { + it('should return 200 with list of action types containing defaults', async () => { + const response = await supertestWithoutAuth + .get(`${getUrlPrefix(space.id)}/api/action/types`) + .auth(user.username, user.password); + + function createActionTypeMatcher(id: string, name: string) { + return (actionType: { id: string; name: string }) => { + return actionType.id === id && actionType.name === name; + }; + } + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + expect(response.statusCode).to.eql(404); + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Not Found', + }); + break; + case 'global_read at space1': + case 'superuser at space1': + case 'space_1_all at space1': + expect(response.statusCode).to.eql(200); + // Check for values explicitly in order to avoid this test failing each time plugins register + // a new action type + expect( + response.body.some( + createActionTypeMatcher('test.index-record', 'Test: Index Record') + ) + ).to.be(true); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + }); + } + }); +} diff --git a/x-pack/test/alerting_api_integration/apis/actions/manual/pr_40694.js b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/manual/pr_40694.js similarity index 100% rename from x-pack/test/alerting_api_integration/apis/actions/manual/pr_40694.js rename to x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/manual/pr_40694.js diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/update.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/update.ts new file mode 100644 index 00000000000000..17eace3dcd93c7 --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/update.ts @@ -0,0 +1,255 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { UserAtSpaceScenarios } from '../../scenarios'; +import { getUrlPrefix, ObjectRemover } from '../../../common/lib'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function updateActionTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + describe('update', () => { + const objectRemover = new ObjectRemover(supertest); + + after(() => objectRemover.removeAll()); + + for (const scenario of UserAtSpaceScenarios) { + const { user, space } = scenario; + describe(scenario.id, () => { + it('should handle update action request appropriately', async () => { + const { body: createdAction } = await supertest + .post(`${getUrlPrefix(space.id)}/api/action`) + .set('kbn-xsrf', 'foo') + .send({ + description: 'My action', + actionTypeId: 'test.index-record', + config: { + unencrypted: `This value shouldn't get encrypted`, + }, + secrets: { + encrypted: 'This value should be encrypted', + }, + }) + .expect(200); + objectRemover.add(space.id, createdAction.id, 'action'); + + const response = await supertestWithoutAuth + .put(`${getUrlPrefix(space.id)}/api/action/${createdAction.id}`) + .auth(user.username, user.password) + .set('kbn-xsrf', 'foo') + .send({ + description: 'My action updated', + config: { + unencrypted: `This value shouldn't get encrypted`, + }, + secrets: { + encrypted: 'This value should be encrypted', + }, + }); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + case 'global_read at space1': + expect(response.statusCode).to.eql(404); + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Not Found', + }); + break; + case 'superuser at space1': + case 'space_1_all at space1': + expect(response.statusCode).to.eql(200); + expect(response.body).to.eql({ + id: createdAction.id, + actionTypeId: 'test.index-record', + description: 'My action updated', + config: { + unencrypted: `This value shouldn't get encrypted`, + }, + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle update action request appropriately when passing a null config', async () => { + const response = await supertestWithoutAuth + .put(`${getUrlPrefix(space.id)}/api/action/1`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .send({ + description: 'My action updated', + config: null, + }); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + case 'global_read at space1': + expect(response.statusCode).to.eql(404); + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Not Found', + }); + break; + case 'superuser at space1': + case 'space_1_all at space1': + expect(response.statusCode).to.eql(400); + expect(response.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: 'child "config" fails because ["config" must be an object]', + validation: { + source: 'payload', + keys: ['config'], + }, + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it(`should handle update action request appropriately when action doesn't exist`, async () => { + const response = await supertestWithoutAuth + .put(`${getUrlPrefix(space.id)}/api/action/1`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .send({ + description: 'My action updated', + config: { + unencrypted: `This value shouldn't get encrypted`, + }, + secrets: { + encrypted: 'This value should be encrypted', + }, + }); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + case 'global_read at space1': + expect(response.statusCode).to.eql(404); + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Not Found', + }); + break; + case 'superuser at space1': + case 'space_1_all at space1': + expect(response.statusCode).to.eql(404); + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Saved object [action/1] not found', + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle update action request appropriately when payload is empty and invalid', async () => { + const response = await supertestWithoutAuth + .put(`${getUrlPrefix(space.id)}/api/action/1`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .send({}); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + case 'global_read at space1': + expect(response.statusCode).to.eql(404); + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Not Found', + }); + break; + case 'superuser at space1': + case 'space_1_all at space1': + expect(response.statusCode).to.eql(400); + expect(response.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: 'child "description" fails because ["description" is required]', + validation: { source: 'payload', keys: ['description'] }, + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle update action request appropriately when secrets are not valid', async () => { + const { body: createdAction } = await supertest + .post(`${getUrlPrefix(space.id)}/api/action`) + .set('kbn-xsrf', 'foo') + .send({ + description: 'My action', + actionTypeId: 'test.index-record', + config: { + unencrypted: `This value shouldn't get encrypted`, + }, + secrets: { + encrypted: 'This value should be encrypted', + }, + }) + .expect(200); + objectRemover.add(space.id, createdAction.id, 'action'); + + const response = await supertestWithoutAuth + .put(`${getUrlPrefix(space.id)}/api/action/${createdAction.id}`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .send({ + description: 'My action updated', + config: { + unencrypted: `This value shouldn't get encrypted`, + }, + secrets: { + encrypted: 42, + }, + }); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + case 'global_read at space1': + expect(response.statusCode).to.eql(404); + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Not Found', + }); + break; + case 'superuser at space1': + case 'space_1_all at space1': + expect(response.statusCode).to.eql(400); + expect(response.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type secrets: [encrypted]: expected value of type [string] but got [number]', + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + }); + } + }); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts new file mode 100644 index 00000000000000..9f3eba4b93fedd --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts @@ -0,0 +1,435 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { UserAtSpaceScenarios } from '../../scenarios'; +import { getUrlPrefix, ObjectRemover } from '../../../common/lib'; +import { getTestAlertData, setupEsTestIndex, destroyEsTestIndex } from './utils'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function alertTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const es = getService('es'); + const retry = getService('retry'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + describe('alerts', () => { + let esTestIndexName: string; + const authorizationIndex = '.kibana-test-authorization'; + const objectRemover = new ObjectRemover(supertest); + + before(async () => { + await destroyEsTestIndex(es); + ({ name: esTestIndexName } = await setupEsTestIndex(es)); + await es.indices.create({ index: authorizationIndex }); + }); + afterEach(() => objectRemover.removeAll()); + after(async () => { + await destroyEsTestIndex(es); + await es.indices.delete({ index: authorizationIndex }); + }); + + async function waitForTestIndexDoc(source: string, reference: string) { + return await retry.try(async () => { + const searchResult = await es.search({ + index: esTestIndexName, + body: { + query: { + bool: { + must: [ + { + term: { + source, + }, + }, + { + term: { + reference, + }, + }, + ], + }, + }, + }, + }); + expect(searchResult.hits.total.value).to.eql(1); + return searchResult.hits.hits[0]; + }); + } + + for (const scenario of UserAtSpaceScenarios) { + const { user, space } = scenario; + describe(scenario.id, () => { + it('should schedule task, run alert and fire actions when appropriate', async () => { + const reference = `create-test-1:${user.username}`; + const { body: createdAction } = await supertest + .post(`${getUrlPrefix(space.id)}/api/action`) + .set('kbn-xsrf', 'foo') + .send({ + description: 'My action', + actionTypeId: 'test.index-record', + config: { + unencrypted: `This value shouldn't get encrypted`, + }, + secrets: { + encrypted: 'This value should be encrypted', + }, + }) + .expect(200); + objectRemover.add(space.id, createdAction.id, 'action'); + + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(space.id)}/api/alert`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .send( + getTestAlertData({ + interval: '1m', + alertTypeId: 'test.always-firing', + alertTypeParams: { + index: esTestIndexName, + reference, + }, + actions: [ + { + group: 'default', + id: createdAction.id, + params: { + index: esTestIndexName, + reference, + message: + 'instanceContextValue: {{context.instanceContextValue}}, instanceStateValue: {{state.instanceStateValue}}', + }, + }, + ], + }) + ); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'global_read at space1': + case 'space_1_all at space2': + expect(response.statusCode).to.eql(404); + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Not Found', + }); + break; + case 'superuser at space1': + case 'space_1_all at space1': + expect(response.statusCode).to.eql(200); + objectRemover.add(space.id, response.body.id, 'alert'); + const alertTestRecord = await waitForTestIndexDoc( + 'alert:test.always-firing', + reference + ); + expect(alertTestRecord._source).to.eql({ + source: 'alert:test.always-firing', + reference, + state: {}, + params: { + index: esTestIndexName, + reference, + }, + }); + const actionTestRecord = await waitForTestIndexDoc( + 'action:test.index-record', + reference + ); + expect(actionTestRecord._source).to.eql({ + config: { + unencrypted: `This value shouldn't get encrypted`, + }, + secrets: { + encrypted: 'This value should be encrypted', + }, + params: { + index: esTestIndexName, + reference, + message: 'instanceContextValue: true, instanceStateValue: true', + }, + reference, + source: 'action:test.index-record', + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle custom retry logic when appropriate', async () => { + // We'll use this start time to query tasks created after this point + const testStart = new Date(); + // We have to provide the test.rate-limit the next runAt, for testing purposes + const retryDate = new Date(Date.now() + 60000); + + const { body: createdAction } = await supertest + .post(`${getUrlPrefix(space.id)}/api/action`) + .set('kbn-xsrf', 'foo') + .send({ + description: 'Test rate limit', + actionTypeId: 'test.rate-limit', + config: {}, + }) + .expect(200); + objectRemover.add(space.id, createdAction.id, 'action'); + + const reference = `create-test-2:${user.username}`; + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(space.id)}/api/alert`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .send( + getTestAlertData({ + interval: '1m', + alertTypeId: 'test.always-firing', + alertTypeParams: { + index: esTestIndexName, + reference: 'create-test-2', + }, + actions: [ + { + group: 'default', + id: createdAction.id, + params: { + reference, + index: esTestIndexName, + retryAt: retryDate.getTime(), + }, + }, + ], + }) + ); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'global_read at space1': + case 'space_1_all at space2': + expect(response.statusCode).to.eql(404); + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Not Found', + }); + break; + case 'superuser at space1': + case 'space_1_all at space1': + expect(response.statusCode).to.eql(200); + objectRemover.add(space.id, response.body.id, 'alert'); + const scheduledActionTask = await retry.try(async () => { + const searchResult = await es.search({ + index: '.kibana_task_manager', + body: { + query: { + bool: { + must: [ + { + term: { + 'task.status': 'idle', + }, + }, + { + term: { + 'task.attempts': 1, + }, + }, + { + term: { + 'task.taskType': 'actions:test.rate-limit', + }, + }, + { + range: { + 'task.scheduledAt': { + gte: testStart, + }, + }, + }, + ], + }, + }, + }, + }); + expect(searchResult.hits.total.value).to.eql(1); + return searchResult.hits.hits[0]; + }); + expect(scheduledActionTask._source.task.runAt).to.eql(retryDate.toISOString()); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should have proper callCluster and savedObjectsClient authorization for alert type executor when appropriate', async () => { + let alertTestRecord: any; + const reference = `create-test-3:${user.username}`; + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(space.id)}/api/alert`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .send( + getTestAlertData({ + alertTypeId: 'test.authorization', + alertTypeParams: { + callClusterAuthorizationIndex: authorizationIndex, + savedObjectsClientType: 'dashboard', + savedObjectsClientId: '1', + index: esTestIndexName, + reference, + }, + }) + ); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + case 'global_read at space1': + expect(response.statusCode).to.eql(404); + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Not Found', + }); + break; + case 'space_1_all at space1': + expect(response.statusCode).to.eql(200); + objectRemover.add(space.id, response.body.id, 'alert'); + alertTestRecord = await waitForTestIndexDoc('alert:test.authorization', reference); + expect(alertTestRecord._source.state).to.eql({ + callClusterSuccess: false, + savedObjectsClientSuccess: false, + callClusterError: { + ...alertTestRecord._source.state.callClusterError, + msg: `[security_exception] action [indices:data/write/bulk[s]] is unauthorized for user [${user.username}]`, + statusCode: 403, + }, + savedObjectsClientError: { + ...alertTestRecord._source.state.savedObjectsClientError, + output: { + ...alertTestRecord._source.state.savedObjectsClientError.output, + statusCode: 403, + }, + }, + }); + break; + case 'superuser at space1': + expect(response.statusCode).to.eql(200); + objectRemover.add(space.id, response.body.id, 'alert'); + alertTestRecord = await waitForTestIndexDoc('alert:test.authorization', reference); + expect(alertTestRecord._source.state).to.eql({ + callClusterSuccess: true, + savedObjectsClientSuccess: false, + savedObjectsClientError: { + ...alertTestRecord._source.state.savedObjectsClientError, + output: { + ...alertTestRecord._source.state.savedObjectsClientError.output, + statusCode: 404, + }, + }, + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should have proper callCluster and savedObjectsClient authorization for action type executor when appropriate', async () => { + let actionTestRecord: any; + const reference = `create-test-4:${user.username}`; + const { body: createdAction } = await supertest + .post(`${getUrlPrefix(space.id)}/api/action`) + .set('kbn-xsrf', 'foo') + .send({ + description: 'My action', + actionTypeId: 'test.authorization', + }) + .expect(200); + objectRemover.add(space.id, createdAction.id, 'action'); + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(space.id)}/api/alert`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .send( + getTestAlertData({ + alertTypeId: 'test.always-firing', + alertTypeParams: { + index: esTestIndexName, + reference, + }, + actions: [ + { + group: 'default', + id: createdAction.id, + params: { + callClusterAuthorizationIndex: authorizationIndex, + savedObjectsClientType: 'dashboard', + savedObjectsClientId: '1', + index: esTestIndexName, + reference, + }, + }, + ], + }) + ); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + case 'global_read at space1': + expect(response.statusCode).to.eql(404); + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Not Found', + }); + break; + case 'space_1_all at space1': + expect(response.statusCode).to.eql(200); + objectRemover.add(space.id, response.body.id, 'alert'); + actionTestRecord = await waitForTestIndexDoc('action:test.authorization', reference); + expect(actionTestRecord._source.state).to.eql({ + callClusterSuccess: false, + savedObjectsClientSuccess: false, + callClusterError: { + ...actionTestRecord._source.state.callClusterError, + msg: `[security_exception] action [indices:data/write/bulk[s]] is unauthorized for user [${user.username}]`, + statusCode: 403, + }, + savedObjectsClientError: { + ...actionTestRecord._source.state.savedObjectsClientError, + output: { + ...actionTestRecord._source.state.savedObjectsClientError.output, + statusCode: 403, + }, + }, + }); + break; + case 'superuser at space1': + expect(response.statusCode).to.eql(200); + objectRemover.add(space.id, response.body.id, 'alert'); + actionTestRecord = await waitForTestIndexDoc('action:test.authorization', reference); + expect(actionTestRecord._source.state).to.eql({ + callClusterSuccess: true, + savedObjectsClientSuccess: false, + savedObjectsClientError: { + ...actionTestRecord._source.state.savedObjectsClientError, + output: { + ...actionTestRecord._source.state.savedObjectsClientError.output, + statusCode: 404, + }, + }, + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + }); + } + }); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/create.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/create.ts new file mode 100644 index 00000000000000..8cba970fdb32c6 --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/create.ts @@ -0,0 +1,296 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { getTestAlertData } from './utils'; +import { UserAtSpaceScenarios } from '../../scenarios'; +import { getUrlPrefix, ObjectRemover } from '../../../common/lib'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function createAlertTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const es = getService('es'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + describe('create', () => { + const objectRemover = new ObjectRemover(supertest); + + after(() => objectRemover.removeAll()); + + async function getScheduledTask(id: string) { + return await es.get({ + id: `task:${id}`, + index: '.kibana_task_manager', + }); + } + + for (const scenario of UserAtSpaceScenarios) { + const { user, space } = scenario; + describe(scenario.id, () => { + it('should handle create alert request appropriately', async () => { + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(space.id)}/api/alert`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .send(getTestAlertData()); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'global_read at space1': + case 'space_1_all at space2': + expect(response.statusCode).to.eql(404); + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Not Found', + }); + break; + case 'superuser at space1': + case 'space_1_all at space1': + expect(response.statusCode).to.eql(200); + objectRemover.add(space.id, response.body.id, 'alert'); + expect(response.body).to.eql({ + id: response.body.id, + actions: [], + enabled: true, + alertTypeId: 'test.noop', + alertTypeParams: {}, + createdBy: user.username, + interval: '10s', + scheduledTaskId: response.body.scheduledTaskId, + updatedBy: user.username, + }); + expect(typeof response.body.scheduledTaskId).to.be('string'); + const { _source: taskRecord } = await getScheduledTask(response.body.scheduledTaskId); + expect(taskRecord.type).to.eql('task'); + expect(taskRecord.task.taskType).to.eql('alerting:test.noop'); + expect(JSON.parse(taskRecord.task.params)).to.eql({ + alertId: response.body.id, + spaceId: space.id, + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle create alert request appropriately when an alert is disabled ', async () => { + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(space.id)}/api/alert`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .send(getTestAlertData({ enabled: false })); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'global_read at space1': + case 'space_1_all at space2': + expect(response.statusCode).to.eql(404); + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Not Found', + }); + break; + case 'superuser at space1': + case 'space_1_all at space1': + expect(response.statusCode).to.eql(200); + objectRemover.add(space.id, response.body.id, 'alert'); + expect(response.body.scheduledTaskId).to.eql(undefined); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle create alert request appropriately when alert type is unregistered', async () => { + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(space.id)}/api/alert`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .send( + getTestAlertData({ + alertTypeId: 'test.unregistered-alert-type', + }) + ); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'global_read at space1': + case 'space_1_all at space2': + expect(response.statusCode).to.eql(404); + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Not Found', + }); + break; + case 'superuser at space1': + case 'space_1_all at space1': + expect(response.statusCode).to.eql(400); + expect(response.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: 'Alert type "test.unregistered-alert-type" is not registered.', + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle create alert request appropriately when payload is empty and invalid', async () => { + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(space.id)}/api/alert`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .send({}); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'global_read at space1': + case 'space_1_all at space2': + expect(response.statusCode).to.eql(404); + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Not Found', + }); + break; + case 'superuser at space1': + case 'space_1_all at space1': + expect(response.statusCode).to.eql(400); + expect(response.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'child "alertTypeId" fails because ["alertTypeId" is required]. child "interval" fails because ["interval" is required]. child "alertTypeParams" fails because ["alertTypeParams" is required]. child "actions" fails because ["actions" is required]', + validation: { + source: 'payload', + keys: ['alertTypeId', 'interval', 'alertTypeParams', 'actions'], + }, + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it(`should handle create alert request appropriately when alertTypeParams isn't valid`, async () => { + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(space.id)}/api/alert`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .send( + getTestAlertData({ + alertTypeId: 'test.validation', + }) + ); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'global_read at space1': + case 'space_1_all at space2': + expect(response.statusCode).to.eql(404); + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Not Found', + }); + break; + case 'superuser at space1': + case 'space_1_all at space1': + expect(response.statusCode).to.eql(400); + expect(response.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'alertTypeParams invalid: [param1]: expected value of type [string] but got [undefined]', + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle create alert request appropriately when interval is wrong syntax', async () => { + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(space.id)}/api/alert`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .send(getTestAlertData(getTestAlertData({ interval: '10x' }))); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'global_read at space1': + case 'space_1_all at space2': + expect(response.statusCode).to.eql(404); + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Not Found', + }); + break; + case 'superuser at space1': + case 'space_1_all at space1': + expect(response.statusCode).to.eql(400); + expect(response.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'child "interval" fails because ["interval" with value "10x" fails to match the seconds (5s) pattern, "interval" with value "10x" fails to match the minutes (5m) pattern, "interval" with value "10x" fails to match the hours (5h) pattern, "interval" with value "10x" fails to match the days (5d) pattern]', + validation: { + source: 'payload', + keys: ['interval', 'interval', 'interval', 'interval'], + }, + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle create alert request appropriately when interval is 0', async () => { + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(space.id)}/api/alert`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .send(getTestAlertData(getTestAlertData({ interval: '0s' }))); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'global_read at space1': + case 'space_1_all at space2': + expect(response.statusCode).to.eql(404); + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Not Found', + }); + break; + case 'superuser at space1': + case 'space_1_all at space1': + expect(response.statusCode).to.eql(400); + expect(response.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'child "interval" fails because ["interval" with value "0s" fails to match the seconds (5s) pattern, "interval" with value "0s" fails to match the minutes (5m) pattern, "interval" with value "0s" fails to match the hours (5h) pattern, "interval" with value "0s" fails to match the days (5d) pattern]', + validation: { + source: 'payload', + keys: ['interval', 'interval', 'interval', 'interval'], + }, + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + }); + } + }); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/delete.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/delete.ts new file mode 100644 index 00000000000000..df995fcc86d33d --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/delete.ts @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { getTestAlertData } from './utils'; +import { UserAtSpaceScenarios } from '../../scenarios'; +import { getUrlPrefix, ObjectRemover } from '../../../common/lib'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function createDeleteTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const es = getService('es'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + describe('delete', () => { + const objectRemover = new ObjectRemover(supertest); + + after(() => objectRemover.removeAll()); + + async function getScheduledTask(id: string) { + return await es.get({ + id: `task:${id}`, + index: '.kibana_task_manager', + }); + } + + for (const scenario of UserAtSpaceScenarios) { + const { user, space } = scenario; + describe(scenario.id, () => { + it('should handle delete alert request appropriately', async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alert`) + .set('kbn-xsrf', 'foo') + .send(getTestAlertData()) + .expect(200); + + const response = await supertestWithoutAuth + .delete(`${getUrlPrefix(space.id)}/api/alert/${createdAlert.id}`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + case 'global_read at space1': + expect(response.statusCode).to.eql(404); + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Not Found', + }); + objectRemover.add(space.id, createdAlert.id, 'alert'); + // Ensure task still exists + await getScheduledTask(createdAlert.scheduledTaskId); + break; + case 'superuser at space1': + case 'space_1_all at space1': + expect(response.statusCode).to.eql(204); + expect(response.body).to.eql(''); + try { + await getScheduledTask(createdAlert.scheduledTaskId); + throw new Error('Should have removed scheduled task'); + } catch (e) { + expect(e.status).to.eql(404); + } + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + }); + } + }); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/disable.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/disable.ts new file mode 100644 index 00000000000000..26f7e501803d4d --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/disable.ts @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { getTestAlertData } from './utils'; +import { UserAtSpaceScenarios } from '../../scenarios'; +import { getUrlPrefix, ObjectRemover } from '../../../common/lib'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function createDisableAlertTests({ getService }: FtrProviderContext) { + const es = getService('es'); + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + describe('disable', () => { + const objectRemover = new ObjectRemover(supertest); + + after(() => objectRemover.removeAll()); + + async function getScheduledTask(id: string) { + return await es.get({ + id: `task:${id}`, + index: '.kibana_task_manager', + }); + } + + for (const scenario of UserAtSpaceScenarios) { + const { user, space } = scenario; + describe(scenario.id, () => { + it('should handle disable alert request appropriately', async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alert`) + .set('kbn-xsrf', 'foo') + .send(getTestAlertData({ enabled: true })) + .expect(200); + objectRemover.add(space.id, createdAlert.id, 'alert'); + + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(space.id)}/api/alert/${createdAlert.id}/_disable`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + case 'global_read at space1': + expect(response.statusCode).to.eql(404); + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Not Found', + }); + // Ensure task still exists + await getScheduledTask(createdAlert.scheduledTaskId); + break; + case 'superuser at space1': + case 'space_1_all at space1': + expect(response.statusCode).to.eql(204); + expect(response.body).to.eql(''); + try { + await getScheduledTask(createdAlert.scheduledTaskId); + throw new Error('Should have removed scheduled task'); + } catch (e) { + expect(e.status).to.eql(404); + } + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + }); + } + }); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/enable.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/enable.ts new file mode 100644 index 00000000000000..4631ee1be94fd2 --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/enable.ts @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { getTestAlertData } from './utils'; +import { UserAtSpaceScenarios } from '../../scenarios'; +import { getUrlPrefix, ObjectRemover } from '../../../common/lib'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function createEnableAlertTests({ getService }: FtrProviderContext) { + const es = getService('es'); + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + describe('enable', () => { + const objectRemover = new ObjectRemover(supertest); + + after(() => objectRemover.removeAll()); + + async function getScheduledTask(id: string) { + return await es.get({ + id: `task:${id}`, + index: '.kibana_task_manager', + }); + } + + for (const scenario of UserAtSpaceScenarios) { + const { user, space } = scenario; + describe(scenario.id, () => { + it('should handle enable alert request appropriately', async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alert`) + .set('kbn-xsrf', 'foo') + .send(getTestAlertData({ enabled: false })) + .expect(200); + objectRemover.add(space.id, createdAlert.id, 'alert'); + + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(space.id)}/api/alert/${createdAlert.id}/_enable`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + case 'global_read at space1': + expect(response.statusCode).to.eql(404); + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Not Found', + }); + break; + case 'superuser at space1': + case 'space_1_all at space1': + expect(response.statusCode).to.eql(204); + expect(response.body).to.eql(''); + const { body: updatedAlert } = await supertestWithoutAuth + .get(`${getUrlPrefix(space.id)}/api/alert/${createdAlert.id}`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .expect(200); + expect(typeof updatedAlert.scheduledTaskId).to.eql('string'); + const { _source: taskRecord } = await getScheduledTask(updatedAlert.scheduledTaskId); + expect(taskRecord.type).to.eql('task'); + expect(taskRecord.task.taskType).to.eql('alerting:test.noop'); + expect(JSON.parse(taskRecord.task.params)).to.eql({ + alertId: createdAlert.id, + spaceId: space.id, + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + }); + } + }); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/find.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/find.ts new file mode 100644 index 00000000000000..733e0924b515c3 --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/find.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { getTestAlertData } from './utils'; +import { UserAtSpaceScenarios } from '../../scenarios'; +import { getUrlPrefix, ObjectRemover } from '../../../common/lib'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function createFindTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + describe('find', () => { + const objectRemover = new ObjectRemover(supertest); + + afterEach(() => objectRemover.removeAll()); + + for (const scenario of UserAtSpaceScenarios) { + const { user, space } = scenario; + describe(scenario.id, () => { + it('should handle find alert request appropriately', async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alert`) + .set('kbn-xsrf', 'foo') + .send(getTestAlertData()) + .expect(200); + objectRemover.add(space.id, createdAlert.id, 'alert'); + + const response = await supertestWithoutAuth + .get( + `${getUrlPrefix(space.id)}/api/alert/_find?search=test.noop&search_fields=alertTypeId` + ) + .auth(user.username, user.password); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + expect(response.statusCode).to.eql(404); + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Not Found', + }); + break; + case 'global_read at space1': + case 'superuser at space1': + case 'space_1_all at space1': + expect(response.statusCode).to.eql(200); + expect(response.body.page).to.equal(1); + expect(response.body.perPage).to.be.greaterThan(0); + expect(response.body.total).to.be.greaterThan(0); + const match = response.body.data.find((obj: any) => obj.id === createdAlert.id); + expect(match).to.eql({ + id: createdAlert.id, + alertTypeId: 'test.noop', + interval: '10s', + enabled: true, + actions: [], + alertTypeParams: {}, + createdBy: 'elastic', + scheduledTaskId: match.scheduledTaskId, + updatedBy: 'elastic', + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + }); + } + }); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/get.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/get.ts new file mode 100644 index 00000000000000..86194bc7aa5c52 --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/get.ts @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { getTestAlertData } from './utils'; +import { UserAtSpaceScenarios } from '../../scenarios'; +import { getUrlPrefix, ObjectRemover } from '../../../common/lib'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function createGetTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + describe('get', () => { + const objectRemover = new ObjectRemover(supertest); + + afterEach(() => objectRemover.removeAll()); + + for (const scenario of UserAtSpaceScenarios) { + const { user, space } = scenario; + describe(scenario.id, () => { + it('should handle get alert request appropriately', async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alert`) + .set('kbn-xsrf', 'foo') + .send(getTestAlertData()) + .expect(200); + objectRemover.add(space.id, createdAlert.id, 'alert'); + + const response = await supertestWithoutAuth + .get(`${getUrlPrefix(space.id)}/api/alert/${createdAlert.id}`) + .auth(user.username, user.password); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + expect(response.statusCode).to.eql(404); + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Not Found', + }); + break; + case 'global_read at space1': + case 'superuser at space1': + case 'space_1_all at space1': + expect(response.statusCode).to.eql(200); + expect(response.body).to.eql({ + id: createdAlert.id, + alertTypeId: 'test.noop', + interval: '10s', + enabled: true, + actions: [], + alertTypeParams: {}, + createdBy: 'elastic', + scheduledTaskId: response.body.scheduledTaskId, + updatedBy: 'elastic', + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it(`should handle get alert request appropriately when alert doesn't exist`, async () => { + const response = await supertestWithoutAuth + .get(`${getUrlPrefix(space.id)}/api/alert/1`) + .auth(user.username, user.password); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + expect(response.statusCode).to.eql(404); + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Not Found', + }); + break; + case 'global_read at space1': + case 'superuser at space1': + case 'space_1_all at space1': + expect(response.statusCode).to.eql(404); + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Saved object [alert/1] not found', + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + }); + } + }); +} diff --git a/x-pack/test/alerting_api_integration/apis/alerting/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/index.ts similarity index 86% rename from x-pack/test/alerting_api_integration/apis/alerting/index.ts rename to x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/index.ts index b613edca8a1152..8db614feda9373 100644 --- a/x-pack/test/alerting_api_integration/apis/alerting/index.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/index.ts @@ -4,8 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { FtrProviderContext } from '../../ftr_provider_context'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +// eslint-disable-next-line import/no-default-export export default function alertingTests({ loadTestFile }: FtrProviderContext) { describe('Alerting', () => { loadTestFile(require.resolve('./create')); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/list_alert_types.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/list_alert_types.ts new file mode 100644 index 00000000000000..fde4d0e880a81f --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/list_alert_types.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { UserAtSpaceScenarios } from '../../scenarios'; +import { getUrlPrefix } from '../../../common/lib/space_test_utils'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function listAlertTypes({ getService }: FtrProviderContext) { + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + describe('list_alert_types', () => { + for (const scenario of UserAtSpaceScenarios) { + const { user, space } = scenario; + describe(scenario.id, () => { + it('should return 200 with list of alert types', async () => { + const response = await supertestWithoutAuth + .get(`${getUrlPrefix(space.id)}/api/alert/types`) + .auth(user.username, user.password); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + expect(response.statusCode).to.eql(404); + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Not Found', + }); + break; + case 'global_read at space1': + case 'superuser at space1': + case 'space_1_all at space1': + expect(response.statusCode).to.eql(200); + const fixtureAlertType = response.body.find( + (alertType: any) => alertType.id === 'test.noop' + ); + expect(fixtureAlertType).to.eql({ + id: 'test.noop', + name: 'Test: Noop', + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + }); + } + }); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/update.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/update.ts new file mode 100644 index 00000000000000..88cea729503a73 --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/update.ts @@ -0,0 +1,249 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { getTestAlertData } from './utils'; +import { UserAtSpaceScenarios } from '../../scenarios'; +import { getUrlPrefix, ObjectRemover } from '../../../common/lib'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function createUpdateTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + describe('update', () => { + const objectRemover = new ObjectRemover(supertest); + + after(() => objectRemover.removeAll()); + + for (const scenario of UserAtSpaceScenarios) { + const { user, space } = scenario; + describe(scenario.id, () => { + it('should handle update alert request appropriately', async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alert`) + .set('kbn-xsrf', 'foo') + .send(getTestAlertData()) + .expect(200); + objectRemover.add(space.id, createdAlert.id, 'alert'); + + const updatedData = { + alertTypeParams: { + foo: true, + }, + interval: '12s', + actions: [], + }; + const response = await supertestWithoutAuth + .put(`${getUrlPrefix(space.id)}/api/alert/${createdAlert.id}`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .send(updatedData); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + case 'global_read at space1': + expect(response.statusCode).to.eql(404); + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Not Found', + }); + break; + case 'superuser at space1': + case 'space_1_all at space1': + expect(response.statusCode).to.eql(200); + expect(response.body).to.eql({ + ...updatedData, + id: createdAlert.id, + updatedBy: user.username, + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle update alert request appropriately when attempting to change alert type', async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alert`) + .set('kbn-xsrf', 'foo') + .send(getTestAlertData()) + .expect(200); + objectRemover.add(space.id, createdAlert.id, 'alert'); + + const response = await supertestWithoutAuth + .put(`${getUrlPrefix(space.id)}/api/alert/${createdAlert.id}`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .send({ + alertTypeId: '1', + alertTypeParams: { + foo: true, + }, + interval: '12s', + actions: [], + }); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + case 'global_read at space1': + expect(response.statusCode).to.eql(404); + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Not Found', + }); + break; + case 'superuser at space1': + case 'space_1_all at space1': + expect(response.statusCode).to.eql(400); + expect(response.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: '"alertTypeId" is not allowed', + validation: { + source: 'payload', + keys: ['alertTypeId'], + }, + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle update alert request appropriately when payload is empty and invalid', async () => { + const response = await supertestWithoutAuth + .put(`${getUrlPrefix(space.id)}/api/alert/1`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .send({}); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + case 'global_read at space1': + expect(response.statusCode).to.eql(404); + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Not Found', + }); + break; + case 'superuser at space1': + case 'space_1_all at space1': + expect(response.statusCode).to.eql(400); + expect(response.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'child "interval" fails because ["interval" is required]. child "alertTypeParams" fails because ["alertTypeParams" is required]. child "actions" fails because ["actions" is required]', + validation: { + source: 'payload', + keys: ['interval', 'alertTypeParams', 'actions'], + }, + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it(`should handle update alert request appropriately when alertTypeConfig isn't valid`, async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alert`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + alertTypeId: 'test.validation', + alertTypeParams: { + param1: 'test', + }, + }) + ) + .expect(200); + objectRemover.add(space.id, createdAlert.id, 'alert'); + + const response = await supertestWithoutAuth + .put(`${getUrlPrefix(space.id)}/api/alert/${createdAlert.id}`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .send({ + interval: '10s', + alertTypeParams: {}, + actions: [], + }); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + case 'global_read at space1': + expect(response.statusCode).to.eql(404); + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Not Found', + }); + break; + case 'superuser at space1': + case 'space_1_all at space1': + expect(response.statusCode).to.eql(400); + expect(response.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'alertTypeParams invalid: [param1]: expected value of type [string] but got [undefined]', + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('sshould handle update alert request appropriately when interval is wrong syntax', async () => { + const response = await supertestWithoutAuth + .put(`${getUrlPrefix(space.id)}/api/alert/1`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .send(getTestAlertData({ interval: '10x', enabled: undefined })); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + case 'global_read at space1': + expect(response.statusCode).to.eql(404); + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Not Found', + }); + break; + case 'superuser at space1': + case 'space_1_all at space1': + expect(response.statusCode).to.eql(400); + expect(response.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'child "interval" fails because ["interval" with value "10x" fails to match the seconds pattern, "interval" with value "10x" fails to match the minutes pattern, "interval" with value "10x" fails to match the hours pattern, "interval" with value "10x" fails to match the days pattern]. "alertTypeId" is not allowed', + validation: { + source: 'payload', + keys: ['interval', 'interval', 'interval', 'interval', 'alertTypeId'], + }, + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + }); + } + }); +} diff --git a/x-pack/test/alerting_api_integration/apis/alerting/utils.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/utils.ts similarity index 79% rename from x-pack/test/alerting_api_integration/apis/alerting/utils.ts rename to x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/utils.ts index 0ae7c86cd453ce..b91f4651c08519 100644 --- a/x-pack/test/alerting_api_integration/apis/alerting/utils.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/utils.ts @@ -4,8 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ES_ARCHIVER_ACTION_ID } from './constants'; - const esTestIndexName = '.kibaka-alerting-test-data'; export function getTestAlertData(attributeOverwrites = {}) { @@ -13,16 +11,7 @@ export function getTestAlertData(attributeOverwrites = {}) { enabled: true, alertTypeId: 'test.noop', interval: '10s', - actions: [ - { - group: 'default', - id: ES_ARCHIVER_ACTION_ID, - params: { - message: - 'instanceContextValue: {{context.instanceContextValue}}, instanceStateValue: {{state.instanceStateValue}}', - }, - }, - ], + actions: [], alertTypeParams: {}, ...attributeOverwrites, }; diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/index.ts new file mode 100644 index 00000000000000..7b7be170d9ff40 --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/index.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SpacesService, SecurityService } from '../../../common/services'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { isCustomRoleSpecification } from '../../common/types'; +import { Spaces, Users } from '../scenarios'; + +// eslint-disable-next-line import/no-default-export +export default function alertingApiIntegrationTests({ + loadTestFile, + getService, +}: FtrProviderContext) { + const securityService: SecurityService = getService('security'); + const spacesService: SpacesService = getService('spaces'); + const esArchiver = getService('esArchiver'); + + describe('alerting api integration', function() { + this.tags('ciGroup8'); + + before(async () => { + for (const space of Spaces) { + await spacesService.create(space); + } + + for (const user of Users) { + const roles = [...(user.role ? [user.role] : []), ...(user.roles ? user.roles : [])]; + + await securityService.user.create(user.username, { + password: user.password, + full_name: user.fullName, + roles: roles.map(role => role.name), + }); + + for (const role of roles) { + if (isCustomRoleSpecification(role)) { + await securityService.role.create(role.name, { + kibana: role.kibana, + elasticsearch: role.elasticsearch, + }); + } + } + } + }); + + after(async () => { + for (const user of Users) { + await securityService.user.delete(user.username); + + const roles = [...(user.role ? [user.role] : []), ...(user.roles ? user.roles : [])]; + for (const role of roles) { + if (isCustomRoleSpecification(role)) { + await securityService.role.delete(role.name); + } + } + } + + await esArchiver.unload('empty_kibana'); + }); + + loadTestFile(require.resolve('./actions')); + loadTestFile(require.resolve('./alerting')); + }); +} diff --git a/x-pack/test/api_integration/apis/xpack_main/features/features.ts b/x-pack/test/api_integration/apis/xpack_main/features/features.ts index 4a650886815969..d803dcad90ac15 100644 --- a/x-pack/test/api_integration/apis/xpack_main/features/features.ts +++ b/x-pack/test/api_integration/apis/xpack_main/features/features.ts @@ -134,6 +134,8 @@ export default function({ getService }: FtrProviderContext) { 'maps', 'uptime', 'siem', + 'alerting', + 'actions', ].sort() ); });