Skip to content

Commit

Permalink
Add security support for alerts and actions (#41389) (#43592)
Browse files Browse the repository at this point in the history
* Initial work

* Cleanup add createAPIKey fn pt1

* Change getServices function to take request as parameter

* Use API key when executing alerts

* Revert task manager using encrypted saved objects

* Store fired actions within a saved object to encrypt API keys

* Fix fireActionId

* Cleanup code, fix type check error

* Add a type for getScopedSavedObjectsClient

* Fix getBasePath and spaceIdToNamespace functions

* Add safety check for API key and action

* Fix integration tests

* Fix broken jest tests

* Cleanup

* Rename generatedApiKey to apiKeyValue

* Ensure access to action record

* Cleanup

* Add unit tests

* Fix variable conflict

* Revert task manager specific code (no longer needed)

* Remove fire terminology

* Move tests to spaces and security folder

* Use ES Archiver to remove spaces (empty_kibana)

* Fix missing pieces

* Convert action tests to run per user

* Convert alerting tests to run per user

* Fix type check issue

* Fix failing test

* Add callCluster and savedObjectsClient authorization tests

* Make savedObjectsClient return 403 for authorization tests

* Cleanup

* Fix test failure

* Common function to get data from test index

* Create ObjectRemover

* Cleanup

* useApiKey now provided to functions instead of relying on condition of two strings

* Fix typo

* Make tests it(...) start with should

* Rename useApiKey to isSecurityEnabled

* Merge apiKeyId and apiKeyValue into one

* Update docs

* Use feature controls for list alert / action types API

* Remove need to add ! in TypeScript for required plugins

* Fix ESLint issue

* Include actions and alertTypeParams into AAD and genereate new API key on update

* Generate random id for API key name attribute

* Include interval in AAD

* Send pre-encoded string

* Fix ExecutorError

* Fix apiKey snapshot

* Fix 'default' typo

* De-compose apiKey

* Refresh API key when enabling / disabling an alert

* Add updatedBy

* Make unauthorized APIs return 404
  • Loading branch information
mikecote committed Aug 20, 2019
1 parent 99e7b20 commit 2c75961
Show file tree
Hide file tree
Showing 113 changed files with 4,691 additions and 3,307 deletions.
3 changes: 2 additions & 1 deletion x-pack/legacy/plugins/actions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<br><br>**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.<br><br>**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.<br><br>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
Expand Down Expand Up @@ -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

Expand Down
14 changes: 14 additions & 0 deletions x-pack/legacy/plugins/actions/mappings.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,19 @@
"type": "binary"
}
}
},
"action_task_params": {
"properties": {
"actionId": {
"type": "keyword"
},
"params": {
"enabled": false,
"type": "object"
},
"apiKey": {
"type": "binary"
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ function getServices() {
}
const actionTypeRegistryParams = {
getServices,
isSecurityEnabled: true,
taskManager: mockTaskManager,
encryptedSavedObjectsPlugin: encryptedSavedObjectsMock.create(),
spaceIdToNamespace: jest.fn().mockReturnValue(undefined),
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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);
});
Expand Down
15 changes: 11 additions & 4 deletions x-pack/legacy/plugins/actions/server/action_type_registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -31,9 +36,11 @@ export class ActionTypeRegistry {
encryptedSavedObjectsPlugin,
spaceIdToNamespace,
getBasePath,
isSecurityEnabled,
}: ConstructorOptions) {
this.taskManager = taskManager;
this.taskRunCreatorFunction = getCreateTaskRunnerFunction({
isSecurityEnabled,
getServices,
actionTypeRegistry: this,
encryptedSavedObjectsPlugin,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ function getServices() {

const actionTypeRegistryParams = {
getServices,
isSecurityEnabled: true,
taskManager: mockTaskManager,
encryptedSavedObjectsPlugin: mockEncryptedSavedObjectsPlugin,
spaceIdToNamespace: jest.fn().mockReturnValue(undefined),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ async function mockSlackExecutor(options: ActionTypeExecutorOptions): Promise<an
beforeAll(() => {
actionTypeRegistry = new ActionTypeRegistry({
getServices,
isSecurityEnabled: true,
taskManager: taskManagerMock.create(),
encryptedSavedObjectsPlugin: mockEncryptedSavedObjectsPlugin,
spaceIdToNamespace: jest.fn().mockReturnValue(undefined),
Expand Down
143 changes: 116 additions & 27 deletions x-pack/legacy/plugins/actions/server/create_execute_function.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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."`
);
});
});
43 changes: 33 additions & 10 deletions x-pack/legacy/plugins/actions/server/create_execute_function.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>;
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<string, string> = {};

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'],
Expand Down
Loading

0 comments on commit 2c75961

Please sign in to comment.