[Alerting] fixes to allow pre-configured actions to be executed (#63432)

resolves https://github.com/elastic/kibana/issues/63162

Most of the support for pre-configured actions has already been added
to Kibana, except for one small piece.  The ability for them to be
executed.  This PR adds that support.
This commit is contained in:
Patrick Mueller 2020-04-14 18:20:50 -04:00 committed by GitHub
parent 0abbb1d9c4
commit 7677764c65
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 445 additions and 18 deletions

View file

@ -23,6 +23,7 @@ describe('execute()', () => {
actionTypeRegistry: actionTypeRegistryMock.create(),
getScopedSavedObjectsClient: jest.fn().mockReturnValueOnce(savedObjectsClient),
isESOUsingEphemeralEncryptionKey: false,
preconfiguredActions: [],
});
savedObjectsClient.get.mockResolvedValueOnce({
id: '123',
@ -68,6 +69,68 @@ describe('execute()', () => {
});
});
test('schedules the action with all given parameters with a preconfigured action', async () => {
const executeFn = createExecuteFunction({
getBasePath,
taskManager: mockTaskManager,
actionTypeRegistry: actionTypeRegistryMock.create(),
getScopedSavedObjectsClient: jest.fn().mockReturnValueOnce(savedObjectsClient),
isESOUsingEphemeralEncryptionKey: false,
preconfiguredActions: [
{
id: '123',
actionTypeId: 'mock-action-preconfigured',
config: {},
isPreconfigured: true,
name: 'x',
secrets: {},
},
],
});
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(mockTaskManager.schedule).toHaveBeenCalledTimes(1);
expect(mockTaskManager.schedule.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"params": Object {
"actionTaskParamsId": "234",
"spaceId": "default",
},
"scope": Array [
"actions",
],
"state": Object {},
"taskType": "actions:mock-action-preconfigured",
},
]
`);
expect(savedObjectsClient.get).not.toHaveBeenCalled();
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({
@ -76,6 +139,7 @@ describe('execute()', () => {
getScopedSavedObjectsClient,
isESOUsingEphemeralEncryptionKey: false,
actionTypeRegistry: actionTypeRegistryMock.create(),
preconfiguredActions: [],
});
savedObjectsClient.get.mockResolvedValueOnce({
id: '123',
@ -125,6 +189,7 @@ describe('execute()', () => {
getScopedSavedObjectsClient,
isESOUsingEphemeralEncryptionKey: false,
actionTypeRegistry: actionTypeRegistryMock.create(),
preconfiguredActions: [],
});
savedObjectsClient.get.mockResolvedValueOnce({
id: '123',
@ -171,6 +236,7 @@ describe('execute()', () => {
getScopedSavedObjectsClient,
isESOUsingEphemeralEncryptionKey: true,
actionTypeRegistry: actionTypeRegistryMock.create(),
preconfiguredActions: [],
});
await expect(
executeFn({
@ -193,6 +259,7 @@ describe('execute()', () => {
getScopedSavedObjectsClient,
isESOUsingEphemeralEncryptionKey: false,
actionTypeRegistry: mockedActionTypeRegistry,
preconfiguredActions: [],
});
mockedActionTypeRegistry.ensureActionTypeEnabled.mockImplementation(() => {
throw new Error('Fail');

View file

@ -6,7 +6,12 @@
import { SavedObjectsClientContract } from '../../../../src/core/server';
import { TaskManagerStartContract } from '../../task_manager/server';
import { GetBasePathFunction, RawAction, ActionTypeRegistryContract } from './types';
import {
GetBasePathFunction,
RawAction,
ActionTypeRegistryContract,
PreConfiguredAction,
} from './types';
interface CreateExecuteFunctionOptions {
taskManager: TaskManagerStartContract;
@ -14,6 +19,7 @@ interface CreateExecuteFunctionOptions {
getBasePath: GetBasePathFunction;
isESOUsingEphemeralEncryptionKey: boolean;
actionTypeRegistry: ActionTypeRegistryContract;
preconfiguredActions: PreConfiguredAction[];
}
export interface ExecuteOptions {
@ -29,6 +35,7 @@ export function createExecuteFunction({
actionTypeRegistry,
getScopedSavedObjectsClient,
isESOUsingEphemeralEncryptionKey,
preconfiguredActions,
}: CreateExecuteFunctionOptions) {
return async function execute({ id, params, spaceId, apiKey }: ExecuteOptions) {
if (isESOUsingEphemeralEncryptionKey === true) {
@ -61,9 +68,9 @@ export function createExecuteFunction({
};
const savedObjectsClient = getScopedSavedObjectsClient(fakeRequest);
const actionSavedObject = await savedObjectsClient.get<RawAction>('action', id);
const actionTypeId = await getActionTypeId(id);
actionTypeRegistry.ensureActionTypeEnabled(actionSavedObject.attributes.actionTypeId);
actionTypeRegistry.ensureActionTypeEnabled(actionTypeId);
const actionTaskParamsRecord = await savedObjectsClient.create('action_task_params', {
actionId: id,
@ -72,7 +79,7 @@ export function createExecuteFunction({
});
await taskManager.schedule({
taskType: `actions:${actionSavedObject.attributes.actionTypeId}`,
taskType: `actions:${actionTypeId}`,
params: {
spaceId,
actionTaskParamsId: actionTaskParamsRecord.id,
@ -80,5 +87,15 @@ export function createExecuteFunction({
state: {},
scope: ['actions'],
});
async function getActionTypeId(actionId: string): Promise<string> {
const pcAction = preconfiguredActions.find(action => action.id === actionId);
if (pcAction) {
return pcAction.actionTypeId;
}
const actionSO = await savedObjectsClient.get<RawAction>('action', actionId);
return actionSO.attributes.actionTypeId;
}
};
}

View file

@ -43,6 +43,7 @@ actionExecutor.initialize({
actionTypeRegistry,
encryptedSavedObjectsPlugin,
eventLogger: eventLoggerMock.create(),
preconfiguredActions: [],
});
beforeEach(() => {
@ -232,6 +233,7 @@ test('throws an error when passing isESOUsingEphemeralEncryptionKey with value o
actionTypeRegistry,
encryptedSavedObjectsPlugin,
eventLogger: eventLoggerMock.create(),
preconfiguredActions: [],
});
await expect(
customActionExecutor.execute(executeParams)

View file

@ -11,6 +11,8 @@ import {
ActionTypeRegistryContract,
GetServicesFunction,
RawAction,
PreConfiguredAction,
Services,
} from '../types';
import { EncryptedSavedObjectsPluginStart } from '../../../encrypted_saved_objects/server';
import { SpacesServiceSetup } from '../../../spaces/server';
@ -24,6 +26,7 @@ export interface ActionExecutorContext {
encryptedSavedObjectsPlugin: EncryptedSavedObjectsPluginStart;
actionTypeRegistry: ActionTypeRegistryContract;
eventLogger: IEventLogger;
preconfiguredActions: PreConfiguredAction[];
}
export interface ExecuteOptions {
@ -72,28 +75,22 @@ export class ActionExecutor {
encryptedSavedObjectsPlugin,
actionTypeRegistry,
eventLogger,
preconfiguredActions,
} = this.actionExecutorContext!;
const services = getServices(request);
const spaceId = spaces && spaces.getSpaceId(request);
const namespace = spaceId && spaceId !== 'default' ? { namespace: spaceId } : {};
// Ensure user can read the action before processing
const {
attributes: { actionTypeId, config, name },
} = await services.savedObjectsClient.get<RawAction>('action', actionId);
const { actionTypeId, name, config, secrets } = await getActionInfo(
services,
encryptedSavedObjectsPlugin,
preconfiguredActions,
actionId,
namespace.namespace
);
actionTypeRegistry.ensureActionTypeEnabled(actionTypeId);
// Only get encrypted attributes here, the remaining attributes can be fetched in
// the savedObjectsClient call
const {
attributes: { secrets },
} = await encryptedSavedObjectsPlugin.getDecryptedAsInternalUser<RawAction>(
'action',
actionId,
namespace
);
const actionType = actionTypeRegistry.get(actionTypeId);
let validatedParams: Record<string, any>;
@ -173,3 +170,50 @@ function actionErrorToMessage(result: ActionTypeExecutorResult): string {
return message;
}
interface ActionInfo {
actionTypeId: string;
name: string;
config: any;
secrets: any;
}
async function getActionInfo(
services: Services,
encryptedSavedObjectsPlugin: EncryptedSavedObjectsPluginStart,
preconfiguredActions: PreConfiguredAction[],
actionId: string,
namespace: string | undefined
): Promise<ActionInfo> {
// check to see if it's a pre-configured action first
const pcAction = preconfiguredActions.find(
preconfiguredAction => preconfiguredAction.id === actionId
);
if (pcAction) {
return {
actionTypeId: pcAction.actionTypeId,
name: pcAction.name,
config: pcAction.config,
secrets: pcAction.secrets,
};
}
// if not pre-configured action, should be a saved object
// ensure user can read the action before processing
const {
attributes: { actionTypeId, config, name },
} = await services.savedObjectsClient.get<RawAction>('action', actionId);
const {
attributes: { secrets },
} = await encryptedSavedObjectsPlugin.getDecryptedAsInternalUser<RawAction>('action', actionId, {
namespace: namespace === 'default' ? undefined : namespace,
});
return {
actionTypeId,
name,
config,
secrets,
};
}

View file

@ -61,6 +61,7 @@ const actionExecutorInitializerParams = {
actionTypeRegistry,
encryptedSavedObjectsPlugin: mockedEncryptedSavedObjectsPlugin,
eventLogger: eventLoggerMock.create(),
preconfiguredActions: [],
};
const taskRunnerFactoryInitializerParams = {
spaceIdToNamespace,

View file

@ -245,6 +245,7 @@ export class ActionsPlugin implements Plugin<Promise<PluginSetupContract>, Plugi
getServices: this.getServicesFactory(core.savedObjects),
encryptedSavedObjectsPlugin: plugins.encryptedSavedObjects,
actionTypeRegistry: actionTypeRegistry!,
preconfiguredActions,
});
taskRunnerFactory!.initialize({
@ -265,6 +266,7 @@ export class ActionsPlugin implements Plugin<Promise<PluginSetupContract>, Plugi
getScopedSavedObjectsClient: core.savedObjects.getScopedClient,
getBasePath: this.getBasePath,
isESOUsingEphemeralEncryptionKey: isESOUsingEphemeralEncryptionKey!,
preconfiguredActions,
}),
isActionTypeEnabled: id => {
return this.actionTypeRegistry!.isActionTypeEnabled(id);

View file

@ -100,6 +100,27 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions)
xyzSecret2: 'credential2',
},
},
{
id: 'preconfigured-es-index-action',
actionTypeId: '.index',
name: 'preconfigured_es_index_action',
config: {
index: 'functional-test-actions-index-preconfigured',
refresh: true,
executionTimeField: 'timestamp',
},
},
{
id: 'preconfigured.test.index-record',
actionTypeId: 'test.index-record',
name: 'Test:_Preconfigured_Index_Record',
config: {
unencrypted: 'ignored-but-required',
},
secrets: {
encrypted: 'this-is-also-ignored-and-also-required',
},
},
])}`,
...disabledPlugins.map(key => `--xpack.${key}.enabled=false`),
`--plugin-path=${path.join(__dirname, 'fixtures', 'plugins', 'alerts')}`,

View file

@ -0,0 +1,68 @@
/*
* 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 '../../../../common/ftr_provider_context';
// from: x-pack/test/alerting_api_integration/common/config.ts
const ACTION_ID = 'preconfigured-es-index-action';
const ES_TEST_INDEX_NAME = 'functional-test-actions-index-preconfigured';
// eslint-disable-next-line import/no-default-export
export default function indexTest({ getService }: FtrProviderContext) {
const es = getService('legacyEs');
const supertest = getService('supertest');
const esArchiver = getService('esArchiver');
describe('preconfigured index action', () => {
after(() => esArchiver.unload('empty_kibana'));
beforeEach(() => clearTestIndex(es));
it('should execute successfully when expected for a single body', async () => {
const { body: result } = await supertest
.post(`/api/action/${ACTION_ID}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: {
documents: [{ testing: [4, 5, 6] }],
},
})
.expect(200);
expect(result.status).to.eql('ok');
const items = await getTestIndexItems(es);
expect(items.length).to.eql(1);
// check document sans timestamp
const document = items[0]._source;
const timestamp = document.timestamp;
delete document.timestamp;
expect(document).to.eql({ testing: [4, 5, 6] });
// check timestamp
const timestampTime = new Date(timestamp).getTime();
const timeNow = Date.now();
const timeMinuteAgo = timeNow - 1000 * 60;
expect(timestampTime).to.be.within(timeMinuteAgo, timeNow);
});
});
}
async function clearTestIndex(es: any) {
return await es.indices.delete({
index: ES_TEST_INDEX_NAME,
ignoreUnavailable: true,
});
}
async function getTestIndexItems(es: any) {
const result = await es.search({
index: ES_TEST_INDEX_NAME,
});
return result.hits.hits;
}

View file

@ -68,6 +68,18 @@ export default function getAllActionTests({ getService }: FtrProviderContext) {
},
referencedByCount: 0,
},
{
id: 'preconfigured-es-index-action',
isPreconfigured: true,
actionTypeId: '.index',
name: 'preconfigured_es_index_action',
config: {
index: 'functional-test-actions-index-preconfigured',
refresh: true,
executionTimeField: 'timestamp',
},
referencedByCount: 0,
},
{
id: 'my-slack1',
isPreconfigured: true,
@ -90,6 +102,16 @@ export default function getAllActionTests({ getService }: FtrProviderContext) {
},
referencedByCount: 0,
},
{
id: 'preconfigured.test.index-record',
isPreconfigured: true,
actionTypeId: 'test.index-record',
name: 'Test:_Preconfigured_Index_Record',
config: {
unencrypted: 'ignored-but-required',
},
referencedByCount: 0,
},
]);
break;
default:
@ -167,6 +189,18 @@ export default function getAllActionTests({ getService }: FtrProviderContext) {
},
referencedByCount: 1,
},
{
id: 'preconfigured-es-index-action',
isPreconfigured: true,
actionTypeId: '.index',
name: 'preconfigured_es_index_action',
config: {
index: 'functional-test-actions-index-preconfigured',
refresh: true,
executionTimeField: 'timestamp',
},
referencedByCount: 0,
},
{
id: 'my-slack1',
isPreconfigured: true,
@ -189,6 +223,16 @@ export default function getAllActionTests({ getService }: FtrProviderContext) {
},
referencedByCount: 0,
},
{
id: 'preconfigured.test.index-record',
isPreconfigured: true,
actionTypeId: 'test.index-record',
name: 'Test:_Preconfigured_Index_Record',
config: {
unencrypted: 'ignored-but-required',
},
referencedByCount: 0,
},
]);
break;
default:
@ -232,6 +276,18 @@ export default function getAllActionTests({ getService }: FtrProviderContext) {
case 'superuser at space1':
expect(response.statusCode).to.eql(200);
expect(response.body).to.eql([
{
id: 'preconfigured-es-index-action',
isPreconfigured: true,
actionTypeId: '.index',
name: 'preconfigured_es_index_action',
config: {
index: 'functional-test-actions-index-preconfigured',
refresh: true,
executionTimeField: 'timestamp',
},
referencedByCount: 0,
},
{
id: 'my-slack1',
isPreconfigured: true,
@ -254,6 +310,16 @@ export default function getAllActionTests({ getService }: FtrProviderContext) {
},
referencedByCount: 0,
},
{
id: 'preconfigured.test.index-record',
isPreconfigured: true,
actionTypeId: 'test.index-record',
name: 'Test:_Preconfigured_Index_Record',
config: {
unencrypted: 'ignored-but-required',
},
referencedByCount: 0,
},
]);
break;
default:

View file

@ -11,6 +11,7 @@ export default function actionsTests({ loadTestFile }: FtrProviderContext) {
describe('Actions', () => {
loadTestFile(require.resolve('./builtin_action_types/email'));
loadTestFile(require.resolve('./builtin_action_types/es_index'));
loadTestFile(require.resolve('./builtin_action_types/es_index_preconfigured'));
loadTestFile(require.resolve('./builtin_action_types/pagerduty'));
loadTestFile(require.resolve('./builtin_action_types/server_log'));
loadTestFile(require.resolve('./builtin_action_types/servicenow'));

View file

@ -165,6 +165,100 @@ instanceStateValue: true
}
});
it('should schedule task, run alert and schedule preconfigured actions when appropriate', async () => {
const testStart = new Date();
const reference = alertUtils.generateReference();
const response = await alertUtils.createAlwaysFiringAction({
reference,
indexRecordActionId: 'preconfigured.test.index-record',
});
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);
// Wait for the action to index a document before disabling the alert and waiting for tasks to finish
await esTestIndexTool.waitForDocs('action:test.index-record', reference);
await taskManagerUtils.waitForAllTasksIdle(testStart);
const alertId = response.body.id;
await alertUtils.disable(alertId);
await taskManagerUtils.waitForEmpty(testStart);
// Ensure only 1 alert executed with proper params
const alertSearchResult = await esTestIndexTool.search(
'alert:test.always-firing',
reference
);
expect(alertSearchResult.hits.total.value).to.eql(1);
expect(alertSearchResult.hits.hits[0]._source).to.eql({
source: 'alert:test.always-firing',
reference,
state: {},
params: {
index: ES_TEST_INDEX_NAME,
reference,
},
alertInfo: {
alertId,
spaceId: space.id,
namespace: space.id,
name: 'abc',
tags: ['tag-A', 'tag-B'],
createdBy: user.fullName,
updatedBy: user.fullName,
},
});
// Ensure only 1 action executed with proper params
const actionSearchResult = await esTestIndexTool.search(
'action:test.index-record',
reference
);
expect(actionSearchResult.hits.total.value).to.eql(1);
expect(actionSearchResult.hits.hits[0]._source).to.eql({
config: {
unencrypted: 'ignored-but-required',
},
secrets: {
encrypted: 'this-is-also-ignored-and-also-required',
},
params: {
index: ES_TEST_INDEX_NAME,
reference,
message: `
alertId: ${alertId},
alertName: abc,
spaceId: ${space.id},
tags: tag-A,tag-B,
alertInstanceId: 1,
instanceContextValue: true,
instanceStateValue: true
`.trim(),
},
reference,
source: 'action:test.index-record',
});
await taskManagerUtils.waitForActionTaskParamsToBeCleanedUp(testStart);
break;
default:
throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);
}
});
it('should pass updated alert params to executor', async () => {
const testStart = new Date();
// create an alert

View file

@ -45,6 +45,18 @@ export default function getAllActionTests({ getService }: FtrProviderContext) {
},
referencedByCount: 0,
},
{
id: 'preconfigured-es-index-action',
isPreconfigured: true,
actionTypeId: '.index',
name: 'preconfigured_es_index_action',
config: {
index: 'functional-test-actions-index-preconfigured',
refresh: true,
executionTimeField: 'timestamp',
},
referencedByCount: 0,
},
{
id: 'my-slack1',
isPreconfigured: true,
@ -67,6 +79,16 @@ export default function getAllActionTests({ getService }: FtrProviderContext) {
},
referencedByCount: 0,
},
{
id: 'preconfigured.test.index-record',
isPreconfigured: true,
actionTypeId: 'test.index-record',
name: 'Test:_Preconfigured_Index_Record',
config: {
unencrypted: 'ignored-but-required',
},
referencedByCount: 0,
},
]);
});
@ -88,6 +110,18 @@ export default function getAllActionTests({ getService }: FtrProviderContext) {
objectRemover.add(Spaces.space1.id, createdAction.id, 'action');
await supertest.get(`${getUrlPrefix(Spaces.other.id)}/api/action/_getAll`).expect(200, [
{
id: 'preconfigured-es-index-action',
isPreconfigured: true,
actionTypeId: '.index',
name: 'preconfigured_es_index_action',
config: {
index: 'functional-test-actions-index-preconfigured',
refresh: true,
executionTimeField: 'timestamp',
},
referencedByCount: 0,
},
{
id: 'my-slack1',
isPreconfigured: true,
@ -110,6 +144,16 @@ export default function getAllActionTests({ getService }: FtrProviderContext) {
},
referencedByCount: 0,
},
{
id: 'preconfigured.test.index-record',
isPreconfigured: true,
actionTypeId: 'test.index-record',
name: 'Test:_Preconfigured_Index_Record',
config: {
unencrypted: 'ignored-but-required',
},
referencedByCount: 0,
},
]);
});
});