Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Create REST API to fire actions #39463

Merged
merged 10 commits into from
Jun 26, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion x-pack/legacy/plugins/actions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ Params:
|---|---|---|
|id|The id of the action you're trying to get.|string|

#### `GET /api/action/types` List action types
#### `GET /api/action/types`: List action types

No parameters.

Expand All @@ -143,6 +143,20 @@ Payload:
|references|An array of `name`, `type` and `id`. This is the same as `references` in the saved objects API. See the saved objects API documentation.<br><br>In most cases, you can leave this empty.|Array|
|version|The document version when read|string|

#### `POST /api/action/{id}/_fire`: Fire action

Params:

|Property|Description|Type|
|---|---|---|
|id|The id of the action you're trying to fire.|string|

Payload:

|Property|Description|Type|
|---|---|---|
|params|The parameters the action type requires for the execution.|object|

## Firing actions

The plugin exposes a fire function that you can use to fire actions.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/

import { ActionTypeRegistry } from './action_type_registry';

type ActionTypeRegistryContract = PublicMethodsOf<ActionTypeRegistry>;
import { ActionTypeRegistryContract } from './types';

const createActionTypeRegistryMock = () => {
const mocked: jest.Mocked<ActionTypeRegistryContract> = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,7 @@ Array [
`);
expect(getCreateTaskRunnerFunction).toHaveBeenCalledTimes(1);
const call = getCreateTaskRunnerFunction.mock.calls[0][0];
expect(call.actionType).toMatchInlineSnapshot(`
Object {
"executor": [MockFunction],
"id": "my-action-type",
"name": "My action type",
}
`);
expect(call.actionTypeRegistry).toBeTruthy();
expect(call.encryptedSavedObjectsPlugin).toBeTruthy();
expect(call.getServices).toBeTruthy();
});
Expand Down
20 changes: 11 additions & 9 deletions x-pack/legacy/plugins/actions/server/action_type_registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,20 @@

import Boom from 'boom';
import { i18n } from '@kbn/i18n';
import { ActionType, Services } from './types';
import { TaskManager } from '../../task_manager';
import { ActionType, GetServicesFunction } from './types';
import { TaskManager, TaskRunCreatorFunction } from '../../task_manager';
import { getCreateTaskRunnerFunction } from './lib';
import { EncryptedSavedObjectsPlugin } from '../../encrypted_saved_objects';

interface ConstructorOptions {
getServices: (basePath: string) => Services;
taskManager: TaskManager;
getServices: GetServicesFunction;
encryptedSavedObjectsPlugin: EncryptedSavedObjectsPlugin;
}

export class ActionTypeRegistry {
private readonly getServices: (basePath: string) => Services;
private readonly taskRunCreatorFunction: TaskRunCreatorFunction;
private readonly getServices: GetServicesFunction;
private readonly taskManager: TaskManager;
private readonly actionTypes: Map<string, ActionType> = new Map();
private readonly encryptedSavedObjectsPlugin: EncryptedSavedObjectsPlugin;
Expand All @@ -27,6 +28,11 @@ export class ActionTypeRegistry {
this.getServices = getServices;
this.taskManager = taskManager;
this.encryptedSavedObjectsPlugin = encryptedSavedObjectsPlugin;
this.taskRunCreatorFunction = getCreateTaskRunnerFunction({
actionTypeRegistry: this,
getServices: this.getServices,
encryptedSavedObjectsPlugin: this.encryptedSavedObjectsPlugin,
});
}

/**
Expand Down Expand Up @@ -55,11 +61,7 @@ export class ActionTypeRegistry {
[`actions:${actionType.id}`]: {
title: actionType.name,
type: `actions:${actionType.id}`,
createTaskRunner: getCreateTaskRunnerFunction({
actionType,
getServices: this.getServices,
encryptedSavedObjectsPlugin: this.encryptedSavedObjectsPlugin,
}),
createTaskRunner: this.taskRunCreatorFunction,
},
});
}
Expand Down
9 changes: 8 additions & 1 deletion x-pack/legacy/plugins/actions/server/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
getRoute,
updateRoute,
listActionTypesRoute,
fireRoute,
} from './routes';

import { registerBuiltInActionTypes } from './builtin_action_types';
Expand All @@ -33,7 +34,7 @@ export function init(server: Legacy.Server) {
attributesToExcludeFromAAD: new Set(['description']),
});

function getServices(basePath: string): Services {
function getServices(basePath: string, overwrites: Partial<Services> = {}): 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.
Expand All @@ -45,6 +46,7 @@ export function init(server: Legacy.Server) {
log: server.log,
callCluster: callWithInternalUser,
savedObjectsClient: server.savedObjects.getScopedSavedObjectsClient(fakeRequest),
...overwrites,
};
}

Expand All @@ -64,6 +66,11 @@ export function init(server: Legacy.Server) {
findRoute(server);
updateRoute(server);
listActionTypesRoute(server);
fireRoute({
Copy link
Member

Choose a reason for hiding this comment

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

seems slightly weird that this route takes different params than the others. The others don't need actionTypeRegistry nor getServices? Or was this needed for mocking, perhaps for the "immediate fire" API?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah these are functions required by the route (not only for testing). It needs the registry to lookup the action type once loaded from saved objects. And also the getServices to parameterized the execution. This was the only way I was thinking to pass in the singleton instances without exposing anything outside the plugin.

server,
actionTypeRegistry,
getServices,
});

const fireFn = createFireFunction({
taskManager: taskManager!,
Expand Down
163 changes: 163 additions & 0 deletions x-pack/legacy/plugins/actions/server/lib/execute.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/*
* 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 Joi from 'joi';
import { execute } from './execute';
import { actionTypeRegistryMock } from '../action_type_registry.mock';
import { SavedObjectsClientMock } from '../../../../../../src/core/server/mocks';
import { encryptedSavedObjectsMock } from '../../../encrypted_saved_objects/server/plugin.mock';

const savedObjectsClient = SavedObjectsClientMock.create();

function getServices() {
return {
savedObjectsClient,
log: jest.fn(),
callCluster: jest.fn(),
};
}
const encryptedSavedObjectsPlugin = encryptedSavedObjectsMock.create();
const actionTypeRegistry = actionTypeRegistryMock.create();

const executeParams = {
actionId: '1',
namespace: 'some-namespace',
services: getServices(),
params: {
foo: true,
},
actionTypeRegistry,
encryptedSavedObjectsPlugin,
};

beforeEach(() => jest.resetAllMocks());

test('successfully executes', async () => {
const actionType = {
id: 'test',
name: 'Test',
executor: jest.fn(),
};
const actionSavedObject = {
id: '1',
type: 'action',
attributes: {
actionTypeId: 'test',
actionTypeConfig: {
bar: true,
},
actionTypeConfigSecrets: {
baz: true,
},
},
references: [],
};
savedObjectsClient.get.mockResolvedValueOnce(actionSavedObject);
encryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce(actionSavedObject);
actionTypeRegistry.get.mockReturnValueOnce(actionType);
await execute(executeParams);

expect(encryptedSavedObjectsPlugin.getDecryptedAsInternalUser).toHaveBeenCalledWith(
'action',
'1',
{ namespace: 'some-namespace' }
);

expect(actionTypeRegistry.get).toHaveBeenCalledWith('test');

expect(actionType.executor).toHaveBeenCalledWith({
services: expect.anything(),
config: {
bar: true,
baz: true,
},
params: { foo: true },
});
});

test('provides empty config when actionTypeConfig and / or actionTypeConfigSecrets is empty', async () => {
const actionType = {
id: 'test',
name: 'Test',
executor: jest.fn(),
};
const actionSavedObject = {
id: '1',
type: 'action',
attributes: {
actionTypeId: 'test',
},
references: [],
};
savedObjectsClient.get.mockResolvedValueOnce(actionSavedObject);
encryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce(actionSavedObject);
actionTypeRegistry.get.mockReturnValueOnce(actionType);
await execute(executeParams);

expect(actionType.executor).toHaveBeenCalledTimes(1);
const executorCall = actionType.executor.mock.calls[0][0];
expect(executorCall.config).toMatchInlineSnapshot(`Object {}`);
});

test('throws an error when config is invalid', async () => {
const actionType = {
id: 'test',
name: 'Test',
validate: {
config: Joi.object()
.keys({
param1: Joi.string().required(),
})
.required(),
},
executor: jest.fn(),
};
const actionSavedObject = {
id: '1',
type: 'action',
attributes: {
actionTypeId: 'test',
},
references: [],
};
savedObjectsClient.get.mockResolvedValueOnce(actionSavedObject);
encryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce(actionSavedObject);
actionTypeRegistry.get.mockReturnValueOnce(actionType);

await expect(execute(executeParams)).rejects.toThrowErrorMatchingInlineSnapshot(
`"The following actionTypeConfig attributes are invalid: param1 [any.required]"`
);
});

test('throws an error when params is invalid', async () => {
const actionType = {
id: 'test',
name: 'Test',
validate: {
params: Joi.object()
.keys({
param1: Joi.string().required(),
})
.required(),
},
executor: jest.fn(),
};
const actionSavedObject = {
id: '1',
type: 'action',
attributes: {
actionTypeId: 'test',
},
references: [],
};
savedObjectsClient.get.mockResolvedValueOnce(actionSavedObject);
encryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce(actionSavedObject);
actionTypeRegistry.get.mockReturnValueOnce(actionType);

await expect(execute(executeParams)).rejects.toThrowErrorMatchingInlineSnapshot(
`"params invalid: child \\"param1\\" fails because [\\"param1\\" is required]"`
);
});
45 changes: 45 additions & 0 deletions x-pack/legacy/plugins/actions/server/lib/execute.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/*
* 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 { Services, ActionTypeRegistryContract } from '../types';
import { validateActionTypeConfig } from './validate_action_type_config';
import { validateActionTypeParams } from './validate_action_type_params';
import { EncryptedSavedObjectsPlugin } from '../../../encrypted_saved_objects';

interface ExecuteOptions {
actionId: string;
namespace: string;
services: Services;
params: Record<string, any>;
encryptedSavedObjectsPlugin: EncryptedSavedObjectsPlugin;
actionTypeRegistry: ActionTypeRegistryContract;
}

export async function execute({
actionId,
namespace,
actionTypeRegistry,
services,
params,
encryptedSavedObjectsPlugin,
}: ExecuteOptions) {
// TODO: Ensure user can read the action before processing
const action = await encryptedSavedObjectsPlugin.getDecryptedAsInternalUser('action', actionId, {
namespace,
});
const actionType = actionTypeRegistry.get(action.attributes.actionTypeId);
const mergedActionTypeConfig = {
...(action.attributes.actionTypeConfig || {}),
...(action.attributes.actionTypeConfigSecrets || {}),
};
const validatedConfig = validateActionTypeConfig(actionType, mergedActionTypeConfig);
const validatedParams = validateActionTypeParams(actionType, params);
await actionType.executor({
services,
config: validatedConfig,
params: validatedParams,
});
}
Loading