[actions] add rule saved object reference to action execution event log doc (#101526)

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

Prior to this PR, when an alerting connection action was executed, the event 
log document generated did not contain a reference to the originating rule. 
This makes it difficult to diagnose problems with connector errors, since 
the error is often in the parameters specified in the actions in the alert.

In this PR, a reference to the alerting rule is added to the saved_objects 
field in the event document for these events.
This commit is contained in:
Patrick Mueller 2021-06-22 15:18:35 -04:00 committed by GitHub
parent 2b0f1256dd
commit 86fb2cc90e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 442 additions and 17 deletions

View file

@ -1676,6 +1676,70 @@ describe('execute()', () => {
name: 'my name',
},
});
await expect(
actionsClient.execute({
actionId,
params: {
name: 'my name',
},
relatedSavedObjects: [
{
id: 'some-id',
typeId: 'some-type-id',
type: 'some-type',
},
],
})
).resolves.toMatchObject({ status: 'ok', actionId });
expect(actionExecutor.execute).toHaveBeenCalledWith({
actionId,
request,
params: {
name: 'my name',
},
relatedSavedObjects: [
{
id: 'some-id',
typeId: 'some-type-id',
type: 'some-type',
},
],
});
await expect(
actionsClient.execute({
actionId,
params: {
name: 'my name',
},
relatedSavedObjects: [
{
id: 'some-id',
typeId: 'some-type-id',
type: 'some-type',
namespace: 'some-namespace',
},
],
})
).resolves.toMatchObject({ status: 'ok', actionId });
expect(actionExecutor.execute).toHaveBeenCalledWith({
actionId,
request,
params: {
name: 'my name',
},
relatedSavedObjects: [
{
id: 'some-id',
typeId: 'some-type-id',
type: 'some-type',
namespace: 'some-namespace',
},
],
});
});
});

View file

@ -469,6 +469,7 @@ export class ActionsClient {
actionId,
params,
source,
relatedSavedObjects,
}: Omit<ExecuteOptions, 'request'>): Promise<ActionTypeExecutorResult<unknown>> {
if (
(await getAuthorizationModeBySource(this.unsecuredSavedObjectsClient, source)) ===
@ -476,7 +477,13 @@ export class ActionsClient {
) {
await this.authorization.ensureAuthorized('execute');
}
return this.actionExecutor.execute({ actionId, params, source, request: this.request });
return this.actionExecutor.execute({
actionId,
params,
source,
request: this.request,
relatedSavedObjects,
});
}
public async enqueueExecution(options: EnqueueExecutionOptions): Promise<void> {

View file

@ -83,6 +83,62 @@ describe('execute()', () => {
});
});
test('schedules the action with all given parameters and relatedSavedObjects', async () => {
const actionTypeRegistry = actionTypeRegistryMock.create();
const executeFn = createExecutionEnqueuerFunction({
taskManager: mockTaskManager,
actionTypeRegistry,
isESOCanEncrypt: true,
preconfiguredActions: [],
});
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(savedObjectsClient, {
id: '123',
params: { baz: false },
spaceId: 'default',
apiKey: Buffer.from('123:abc').toString('base64'),
source: asHttpRequestExecutionSource(request),
relatedSavedObjects: [
{
id: 'some-id',
namespace: 'some-namespace',
type: 'some-type',
typeId: 'some-typeId',
},
],
});
expect(savedObjectsClient.create).toHaveBeenCalledWith(
'action_task_params',
{
actionId: '123',
params: { baz: false },
apiKey: Buffer.from('123:abc').toString('base64'),
relatedSavedObjects: [
{
id: 'some-id',
namespace: 'some-namespace',
type: 'some-type',
typeId: 'some-typeId',
},
],
},
{}
);
});
test('schedules the action with all given parameters with a preconfigured action', async () => {
const executeFn = createExecutionEnqueuerFunction({
taskManager: mockTaskManager,

View file

@ -11,6 +11,7 @@ import { RawAction, ActionTypeRegistryContract, PreConfiguredAction } from './ty
import { ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE } from './constants/saved_objects';
import { ExecuteOptions as ActionExecutorOptions } from './lib/action_executor';
import { isSavedObjectExecutionSource } from './lib';
import { RelatedSavedObjects } from './lib/related_saved_objects';
interface CreateExecuteFunctionOptions {
taskManager: TaskManagerStartContract;
@ -23,6 +24,7 @@ export interface ExecuteOptions extends Pick<ActionExecutorOptions, 'params' | '
id: string;
spaceId: string;
apiKey: string | null;
relatedSavedObjects?: RelatedSavedObjects;
}
export type ExecutionEnqueuer = (
@ -38,7 +40,7 @@ export function createExecutionEnqueuerFunction({
}: CreateExecuteFunctionOptions) {
return async function execute(
unsecuredSavedObjectsClient: SavedObjectsClientContract,
{ id, params, spaceId, source, apiKey }: ExecuteOptions
{ id, params, spaceId, source, apiKey, relatedSavedObjects }: ExecuteOptions
) {
if (!isESOCanEncrypt) {
throw new Error(
@ -68,6 +70,7 @@ export function createExecutionEnqueuerFunction({
actionId: id,
params,
apiKey,
relatedSavedObjects,
},
executionSourceAsSavedObjectReferences(source)
);

View file

@ -22,6 +22,7 @@ import { EVENT_LOG_ACTIONS } from '../constants/event_log';
import { IEvent, IEventLogger, SAVED_OBJECT_REL_PRIMARY } from '../../../event_log/server';
import { ActionsClient } from '../actions_client';
import { ActionExecutionSource } from './action_execution_source';
import { RelatedSavedObjects } from './related_saved_objects';
export interface ActionExecutorContext {
logger: Logger;
@ -42,6 +43,7 @@ export interface ExecuteOptions<Source = unknown> {
request: KibanaRequest;
params: Record<string, unknown>;
source?: ActionExecutionSource<Source>;
relatedSavedObjects?: RelatedSavedObjects;
}
export type ActionExecutorContract = PublicMethodsOf<ActionExecutor>;
@ -68,6 +70,7 @@ export class ActionExecutor {
params,
request,
source,
relatedSavedObjects,
}: ExecuteOptions): Promise<ActionTypeExecutorResult<unknown>> {
if (!this.isInitialized) {
throw new Error('ActionExecutor not initialized');
@ -154,6 +157,16 @@ export class ActionExecutor {
},
};
for (const relatedSavedObject of relatedSavedObjects || []) {
event.kibana?.saved_objects?.push({
rel: SAVED_OBJECT_REL_PRIMARY,
type: relatedSavedObject.type,
id: relatedSavedObject.id,
type_id: relatedSavedObject.typeId,
namespace: relatedSavedObject.namespace,
});
}
eventLogger.startTiming(event);
let rawResult: ActionTypeExecutorResult<unknown>;
try {

View file

@ -0,0 +1,86 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { validatedRelatedSavedObjects } from './related_saved_objects';
import { loggingSystemMock } from '../../../../../src/core/server/mocks';
import { Logger } from '../../../../../src/core/server';
const loggerMock = loggingSystemMock.createLogger();
describe('related_saved_objects', () => {
beforeEach(() => {
jest.resetAllMocks();
});
it('validates valid objects', () => {
ensureValid(loggerMock, undefined);
ensureValid(loggerMock, []);
ensureValid(loggerMock, [
{
id: 'some-id',
type: 'some-type',
},
]);
ensureValid(loggerMock, [
{
id: 'some-id',
type: 'some-type',
typeId: 'some-type-id',
},
]);
ensureValid(loggerMock, [
{
id: 'some-id',
type: 'some-type',
namespace: 'some-namespace',
},
]);
ensureValid(loggerMock, [
{
id: 'some-id',
type: 'some-type',
typeId: 'some-type-id',
namespace: 'some-namespace',
},
]);
ensureValid(loggerMock, [
{
id: 'some-id',
type: 'some-type',
},
{
id: 'some-id-2',
type: 'some-type-2',
},
]);
});
});
it('handles invalid objects', () => {
ensureInvalid(loggerMock, 42);
ensureInvalid(loggerMock, {});
ensureInvalid(loggerMock, [{}]);
ensureInvalid(loggerMock, [{ id: 'some-id' }]);
ensureInvalid(loggerMock, [{ id: 42 }]);
ensureInvalid(loggerMock, [{ id: 'some-id', type: 'some-type', x: 42 }]);
});
function ensureValid(logger: Logger, savedObjects: unknown) {
const result = validatedRelatedSavedObjects(logger, savedObjects);
expect(result).toEqual(savedObjects === undefined ? [] : savedObjects);
expect(loggerMock.warn).not.toHaveBeenCalled();
}
function ensureInvalid(logger: Logger, savedObjects: unknown) {
const result = validatedRelatedSavedObjects(logger, savedObjects);
expect(result).toEqual([]);
const message = loggerMock.warn.mock.calls[0][0];
expect(message).toMatch(
/ignoring invalid related saved objects: expected value of type \[array\] but got/
);
}

View file

@ -0,0 +1,31 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { schema, TypeOf } from '@kbn/config-schema';
import { Logger } from '../../../../../src/core/server';
export type RelatedSavedObjects = TypeOf<typeof RelatedSavedObjectsSchema>;
const RelatedSavedObjectsSchema = schema.arrayOf(
schema.object({
namespace: schema.maybe(schema.string({ minLength: 1 })),
id: schema.string({ minLength: 1 }),
type: schema.string({ minLength: 1 }),
// optional; for SO types like action/alert that have type id's
typeId: schema.maybe(schema.string({ minLength: 1 })),
}),
{ defaultValue: [] }
);
export function validatedRelatedSavedObjects(logger: Logger, data: unknown): RelatedSavedObjects {
try {
return RelatedSavedObjectsSchema.validate(data);
} catch (err) {
logger.warn(`ignoring invalid related saved objects: ${err.message}`);
return [];
}
}

View file

@ -126,6 +126,7 @@ test('executes the task by calling the executor with proper parameters', async (
expect(mockedActionExecutor.execute).toHaveBeenCalledWith({
actionId: '2',
params: { baz: true },
relatedSavedObjects: [],
request: expect.objectContaining({
headers: {
// base64 encoded "123:abc"
@ -247,6 +248,7 @@ test('uses API key when provided', async () => {
expect(mockedActionExecutor.execute).toHaveBeenCalledWith({
actionId: '2',
params: { baz: true },
relatedSavedObjects: [],
request: expect.objectContaining({
headers: {
// base64 encoded "123:abc"
@ -262,6 +264,79 @@ test('uses API key when provided', async () => {
);
});
test('uses relatedSavedObjects when provided', async () => {
const taskRunner = taskRunnerFactory.create({
taskInstance: mockedTaskInstance,
});
mockedActionExecutor.execute.mockResolvedValueOnce({ status: 'ok', actionId: '2' });
spaceIdToNamespace.mockReturnValueOnce('namespace-test');
mockedEncryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce({
id: '3',
type: 'action_task_params',
attributes: {
actionId: '2',
params: { baz: true },
apiKey: Buffer.from('123:abc').toString('base64'),
relatedSavedObjects: [{ id: 'some-id', type: 'some-type' }],
},
references: [],
});
await taskRunner.run();
expect(mockedActionExecutor.execute).toHaveBeenCalledWith({
actionId: '2',
params: { baz: true },
relatedSavedObjects: [
{
id: 'some-id',
type: 'some-type',
},
],
request: expect.objectContaining({
headers: {
// base64 encoded "123:abc"
authorization: 'ApiKey MTIzOmFiYw==',
},
}),
});
});
test('sanitizes invalid relatedSavedObjects when provided', async () => {
const taskRunner = taskRunnerFactory.create({
taskInstance: mockedTaskInstance,
});
mockedActionExecutor.execute.mockResolvedValueOnce({ status: 'ok', actionId: '2' });
spaceIdToNamespace.mockReturnValueOnce('namespace-test');
mockedEncryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce({
id: '3',
type: 'action_task_params',
attributes: {
actionId: '2',
params: { baz: true },
apiKey: Buffer.from('123:abc').toString('base64'),
relatedSavedObjects: [{ Xid: 'some-id', type: 'some-type' }],
},
references: [],
});
await taskRunner.run();
expect(mockedActionExecutor.execute).toHaveBeenCalledWith({
actionId: '2',
params: { baz: true },
relatedSavedObjects: [],
request: expect.objectContaining({
headers: {
// base64 encoded "123:abc"
authorization: 'ApiKey MTIzOmFiYw==',
},
}),
});
});
test(`doesn't use API key when not provided`, async () => {
const factory = new TaskRunnerFactory(mockedActionExecutor);
factory.initialize(taskRunnerFactoryInitializerParams);
@ -284,6 +359,7 @@ test(`doesn't use API key when not provided`, async () => {
expect(mockedActionExecutor.execute).toHaveBeenCalledWith({
actionId: '2',
params: { baz: true },
relatedSavedObjects: [],
request: expect.objectContaining({
headers: {},
}),

View file

@ -30,6 +30,7 @@ import {
} from '../types';
import { ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE } from '../constants/saved_objects';
import { asSavedObjectExecutionSource } from './action_execution_source';
import { validatedRelatedSavedObjects } from './related_saved_objects';
export interface TaskRunnerContext {
logger: Logger;
@ -77,7 +78,7 @@ export class TaskRunnerFactory {
const namespace = spaceIdToNamespace(spaceId);
const {
attributes: { actionId, params, apiKey },
attributes: { actionId, params, apiKey, relatedSavedObjects },
references,
} = await encryptedSavedObjectsClient.getDecryptedAsInternalUser<ActionTaskParams>(
ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE,
@ -117,6 +118,7 @@ export class TaskRunnerFactory {
actionId,
request: fakeRequest,
...getSourceFromReferences(references),
relatedSavedObjects: validatedRelatedSavedObjects(logger, relatedSavedObjects),
});
} catch (e) {
if (e instanceof ActionTypeDisabledError) {

View file

@ -65,6 +65,7 @@ describe('executeActionRoute', () => {
someData: 'data',
},
source: asHttpRequestExecutionSource(req),
relatedSavedObjects: [],
});
expect(res.ok).toHaveBeenCalled();
@ -101,6 +102,7 @@ describe('executeActionRoute', () => {
expect(actionsClient.execute).toHaveBeenCalledWith({
actionId: '1',
params: {},
relatedSavedObjects: [],
source: asHttpRequestExecutionSource(req),
});

View file

@ -53,6 +53,7 @@ export const executeActionRoute = (
params,
actionId: id,
source: asHttpRequestExecutionSource(req),
relatedSavedObjects: [],
});
return body
? res.ok({

View file

@ -63,6 +63,7 @@ describe('executeActionRoute', () => {
someData: 'data',
},
source: asHttpRequestExecutionSource(req),
relatedSavedObjects: [],
});
expect(res.ok).toHaveBeenCalled();
@ -100,6 +101,7 @@ describe('executeActionRoute', () => {
actionId: '1',
params: {},
source: asHttpRequestExecutionSource(req),
relatedSavedObjects: [],
});
expect(res.ok).not.toHaveBeenCalled();

View file

@ -48,6 +48,7 @@ export const executeActionRoute = (
params,
actionId: id,
source: asHttpRequestExecutionSource(req),
relatedSavedObjects: [],
});
return body
? res.ok({

View file

@ -35,6 +35,10 @@
},
"apiKey": {
"type": "binary"
},
"relatedSavedObjects": {
"enabled": false,
"type": "object"
}
}
}

View file

@ -135,6 +135,14 @@ test('enqueues execution per selected action', async () => {
"foo": true,
"stateVal": "My goes here",
},
"relatedSavedObjects": Array [
Object {
"id": "1",
"namespace": "test1",
"type": "alert",
"typeId": "test",
},
],
"source": Object {
"source": Object {
"id": "1",
@ -247,6 +255,14 @@ test(`doesn't call actionsPlugin.execute for disabled actionTypes`, async () =>
id: '1',
type: 'alert',
}),
relatedSavedObjects: [
{
id: '1',
namespace: 'test1',
type: 'alert',
typeId: 'test',
},
],
spaceId: 'test1',
apiKey: createExecutionHandlerParams.apiKey,
});
@ -327,6 +343,14 @@ test('context attribute gets parameterized', async () => {
"foo": true,
"stateVal": "My goes here",
},
"relatedSavedObjects": Array [
Object {
"id": "1",
"namespace": "test1",
"type": "alert",
"typeId": "test",
},
],
"source": Object {
"source": Object {
"id": "1",
@ -360,6 +384,14 @@ test('state attribute gets parameterized', async () => {
"foo": true,
"stateVal": "My state-val goes here",
},
"relatedSavedObjects": Array [
Object {
"id": "1",
"namespace": "test1",
"type": "alert",
"typeId": "test",
},
],
"source": Object {
"source": Object {
"id": "1",

View file

@ -157,6 +157,8 @@ export function createExecutionHandler<
continue;
}
const namespace = spaceId === 'default' ? {} : { namespace: spaceId };
// TODO would be nice to add the action name here, but it's not available
const actionLabel = `${action.actionTypeId}:${action.id}`;
const actionsClient = await actionsPlugin.getActionsClientWithRequest(request);
@ -169,10 +171,16 @@ export function createExecutionHandler<
id: alertId,
type: 'alert',
}),
relatedSavedObjects: [
{
id: alertId,
type: 'alert',
namespace: namespace.namespace,
typeId: alertType.id,
},
],
});
const namespace = spaceId === 'default' ? {} : { namespace: spaceId };
const event: IEvent = {
event: {
action: EVENT_LOG_ACTIONS.executeAction,

View file

@ -352,6 +352,14 @@ describe('Task Runner', () => {
"params": Object {
"foo": true,
},
"relatedSavedObjects": Array [
Object {
"id": "1",
"namespace": undefined,
"type": "alert",
"typeId": "test",
},
],
"source": Object {
"source": Object {
"id": "1",
@ -1098,6 +1106,14 @@ describe('Task Runner', () => {
"params": Object {
"foo": true,
},
"relatedSavedObjects": Array [
Object {
"id": "1",
"namespace": undefined,
"type": "alert",
"typeId": "test",
},
],
"source": Object {
"source": Object {
"id": "1",
@ -1634,6 +1650,14 @@ describe('Task Runner', () => {
"params": Object {
"isResolved": true,
},
"relatedSavedObjects": Array [
Object {
"id": "1",
"namespace": undefined,
"type": "alert",
"typeId": "test",
},
],
"source": Object {
"source": Object {
"id": "1",
@ -1826,6 +1850,14 @@ describe('Task Runner', () => {
"params": Object {
"isResolved": true,
},
"relatedSavedObjects": Array [
Object {
"id": "1",
"namespace": undefined,
"type": "alert",
"typeId": "test",
},
],
"source": Object {
"source": Object {
"id": "1",

View file

@ -131,7 +131,7 @@ Below is a document in the expected structure, with descriptions of the fields:
instance_id: "alert instance id, for relevant documents",
action_group_id: "alert action group, for relevant documents",
action_subgroup: "alert action subgroup, for relevant documents",
status: "overall alert status, after alert execution",
status: "overall alert status, after rule execution",
},
saved_objects: [
{
@ -160,21 +160,26 @@ plugins:
- `action: execute-via-http` - generated when an action is executed via HTTP request
- `provider: alerting`
- `action: execute` - generated when an alert executor runs
- `action: execute-action` - generated when an alert schedules an action to run
- `action: new-instance` - generated when an alert has a new instance id that is active
- `action: recovered-instance` - generated when an alert has a previously active instance id that is no longer active
- `action: active-instance` - generated when an alert determines an instance id is active
- `action: execute` - generated when a rule executor runs
- `action: execute-action` - generated when a rule schedules an action to run
- `action: new-instance` - generated when a rule has a new instance id that is active
- `action: recovered-instance` - generated when a rule has a previously active instance id that is no longer active
- `action: active-instance` - generated when a rule determines an instance id is active
For the `saved_objects` array elements, these are references to saved objects
associated with the event. For the `alerting` provider, those are alert saved
ojects and for the `actions` provider those are action saved objects. The
`alerts:execute-action` event includes both the alert and action saved object
references. For that event, only the alert reference has the optional `rel`
associated with the event. For the `alerting` provider, those are rule saved
ojects and for the `actions` provider those are connector saved objects. The
`alerts:execute-action` event includes both the rule and connector saved object
references. For that event, only the rule reference has the optional `rel`
property with a `primary` value. This property is used when searching the
event log to indicate which saved objects should be directly searchable via
saved object references. For the `alerts:execute-action` event, searching
only via the alert saved object reference will return the event.
saved object references. For the `alerts:execute-action` event, only searching
via the rule saved object reference will return the event; searching via the
connector save object reference will **NOT** return the event. The
`actions:execute` event also includes both the rule and connector saved object
references, and both of them have the `rel` property with a `primary` value,
allowing those events to be returned in searches of either the rule or
connector.
## Event Log index - associated resources