Skip malformed actions during bulk action scheduling (#192021)

Resolves: #189690

This PR skips only the malformed actions rather than throwing error
which blocks execution of the valid actions as well.

### To verify:

- Create a pre-configured connectors with below config in the
kibana.dev.yml.
Notice that slackWebhook doesn't have any `secrets` config.
```
xpack.actions.preconfigured:
  preconfigured-server-log:
    name: preconfigured-server-log
    actionTypeId: .server-log
  slackWebhook:
    name: preconfigured-slack-webhook
    actionTypeId: .slack    
```

- Create a rule (e.g. always firing) with 2 actions, one
with`preconfigured-server-log` and one with `slackWebhook`
- Let the rule run and observe the actions on your terminal.
- There should be an error from the slackWebhook whereas the
`.server-log` action still works as expected.
This commit is contained in:
Ersin Erdal 2024-09-11 00:54:07 +02:00 committed by GitHub
parent a141818c4f
commit bc8fc413c4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 157 additions and 52 deletions

View file

@ -5,11 +5,11 @@
* 2.0.
*/
import { KibanaRequest } from '@kbn/core/server';
import { KibanaRequest, Logger } from '@kbn/core/server';
import { v4 as uuidv4 } from 'uuid';
import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks';
import { createBulkExecutionEnqueuerFunction } from './create_execute_function';
import { savedObjectsClientMock } from '@kbn/core/server/mocks';
import { loggingSystemMock, savedObjectsClientMock } from '@kbn/core/server/mocks';
import { actionTypeRegistryMock } from './action_type_registry.mock';
import {
asHttpRequestExecutionSource,
@ -21,6 +21,7 @@ const mockTaskManager = taskManagerMock.createStart();
const savedObjectsClient = savedObjectsClientMock.create();
const request = {} as KibanaRequest;
const mockActionsConfig = actionsConfigMock.create();
const mockLogger = loggingSystemMock.create().get() as jest.Mocked<Logger>;
beforeEach(() => {
jest.resetAllMocks();
@ -43,6 +44,7 @@ describe('bulkExecute()', () => {
isESOCanEncrypt: true,
inMemoryConnectors: [],
configurationUtilities: mockActionsConfig,
logger: mockLogger,
});
savedObjectsClient.bulkGet.mockResolvedValueOnce({
saved_objects: [
@ -133,6 +135,7 @@ describe('bulkExecute()', () => {
isESOCanEncrypt: true,
inMemoryConnectors: [],
configurationUtilities: mockActionsConfig,
logger: mockLogger,
});
savedObjectsClient.bulkGet.mockResolvedValueOnce({
saved_objects: [
@ -226,6 +229,7 @@ describe('bulkExecute()', () => {
isESOCanEncrypt: true,
inMemoryConnectors: [],
configurationUtilities: mockActionsConfig,
logger: mockLogger,
});
savedObjectsClient.bulkGet.mockResolvedValueOnce({
saved_objects: [
@ -323,6 +327,7 @@ describe('bulkExecute()', () => {
},
],
configurationUtilities: mockActionsConfig,
logger: mockLogger,
});
const source = { type: 'alert', id: uuidv4() };
@ -422,6 +427,7 @@ describe('bulkExecute()', () => {
},
],
configurationUtilities: mockActionsConfig,
logger: mockLogger,
});
const source = { type: 'alert', id: uuidv4() };
@ -521,6 +527,7 @@ describe('bulkExecute()', () => {
},
],
configurationUtilities: mockActionsConfig,
logger: mockLogger,
});
const source = { type: 'alert', id: uuidv4() };
@ -641,6 +648,7 @@ describe('bulkExecute()', () => {
},
],
configurationUtilities: mockActionsConfig,
logger: mockLogger,
});
const source = { type: 'alert', id: uuidv4() };
@ -750,6 +758,7 @@ describe('bulkExecute()', () => {
actionTypeRegistry: actionTypeRegistryMock.create(),
inMemoryConnectors: [],
configurationUtilities: mockActionsConfig,
logger: mockLogger,
});
await expect(
executeFn(savedObjectsClient, [
@ -768,13 +777,14 @@ describe('bulkExecute()', () => {
);
});
test('throws when isMissingSecrets is true for connector', async () => {
test('filter outs the connectors when isMissingSecrets is true', async () => {
const executeFn = createBulkExecutionEnqueuerFunction({
taskManager: mockTaskManager,
isESOCanEncrypt: true,
actionTypeRegistry: actionTypeRegistryMock.create(),
inMemoryConnectors: [],
configurationUtilities: mockActionsConfig,
logger: mockLogger,
});
savedObjectsClient.bulkGet.mockResolvedValueOnce({
saved_objects: [
@ -788,26 +798,69 @@ describe('bulkExecute()', () => {
},
references: [],
},
{
id: '234',
type: 'action',
attributes: {
name: 'mock-action-2',
isMissingSecrets: false,
actionTypeId: 'mock-action-2',
},
references: [],
},
],
});
await expect(
executeFn(savedObjectsClient, [
savedObjectsClient.bulkCreate.mockResolvedValueOnce({
saved_objects: [
{
id: '123',
params: { baz: false },
spaceId: 'default',
executionId: '123abc',
apiKey: null,
source: asHttpRequestExecutionSource(request),
actionTypeId: 'mock-action',
id: '234',
type: 'action_task_params',
attributes: {
actionId: '123',
},
references: [],
},
])
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Unable to execute action because no secrets are defined for the \\"mock-action\\" connector."`
],
});
const result = await executeFn(savedObjectsClient, [
{
id: '123',
params: { baz: false },
spaceId: 'default',
executionId: '123abc',
apiKey: null,
source: asHttpRequestExecutionSource(request),
actionTypeId: 'mock-action',
},
{
id: '234',
params: { baz: false },
spaceId: 'default',
executionId: '123abc',
apiKey: null,
source: asHttpRequestExecutionSource(request),
actionTypeId: 'mock-action-2',
},
]);
expect(result).toEqual({
errors: false,
items: [
{
actionTypeId: 'mock-action-2',
id: '234',
response: 'success',
},
],
});
expect(mockLogger.warn).toHaveBeenCalledWith(
'Skipped the actions for the connector: mock-action (123). Error: Unable to execute action because no secrets are defined for the "mock-action" connector.'
);
});
test('should ensure action type is enabled', async () => {
test('skips the disabled action types', async () => {
const mockedActionTypeRegistry = actionTypeRegistryMock.create();
const executeFn = createBulkExecutionEnqueuerFunction({
taskManager: mockTaskManager,
@ -815,6 +868,7 @@ describe('bulkExecute()', () => {
actionTypeRegistry: mockedActionTypeRegistry,
inMemoryConnectors: [],
configurationUtilities: mockActionsConfig,
logger: mockLogger,
});
mockedActionTypeRegistry.ensureActionTypeEnabled.mockImplementation(() => {
throw new Error('Fail');
@ -826,25 +880,45 @@ describe('bulkExecute()', () => {
type: 'action',
attributes: {
actionTypeId: 'mock-action',
name: 'test-connector',
},
references: [],
},
],
});
savedObjectsClient.bulkCreate.mockResolvedValueOnce({
saved_objects: [
{
id: '234',
type: 'action_task_params',
attributes: {
actionId: '123',
},
references: [],
},
],
});
await expect(
executeFn(savedObjectsClient, [
{
id: '123',
params: { baz: false },
spaceId: 'default',
executionId: '123abc',
apiKey: null,
source: asHttpRequestExecutionSource(request),
actionTypeId: 'mock-action',
},
])
).rejects.toThrowErrorMatchingInlineSnapshot(`"Fail"`);
const result = await executeFn(savedObjectsClient, [
{
id: '123',
params: { baz: false },
spaceId: 'default',
executionId: '123abc',
apiKey: null,
source: asHttpRequestExecutionSource(request),
actionTypeId: 'mock-action',
},
]);
expect(result).toEqual({
errors: false,
items: [],
});
expect(mockLogger.warn).toHaveBeenCalledWith(
'Skipped the actions for the connector: test-connector (123). Error: Fail'
);
});
test('should skip ensure action type if action type is preconfigured and license is valid', async () => {
@ -866,6 +940,7 @@ describe('bulkExecute()', () => {
},
],
configurationUtilities: mockActionsConfig,
logger: mockLogger,
});
mockedActionTypeRegistry.isActionExecutable.mockImplementation(() => true);
savedObjectsClient.bulkGet.mockResolvedValueOnce({
@ -927,6 +1002,7 @@ describe('bulkExecute()', () => {
},
],
configurationUtilities: mockActionsConfig,
logger: mockLogger,
});
mockedActionTypeRegistry.isActionExecutable.mockImplementation(() => true);
savedObjectsClient.bulkGet.mockResolvedValueOnce({
@ -984,6 +1060,7 @@ describe('bulkExecute()', () => {
isESOCanEncrypt: true,
inMemoryConnectors: [],
configurationUtilities: mockActionsConfig,
logger: mockLogger,
});
savedObjectsClient.bulkGet.mockResolvedValueOnce({
saved_objects: [],

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { SavedObjectsBulkResponse, SavedObjectsClientContract } from '@kbn/core/server';
import { SavedObjectsBulkResponse, SavedObjectsClientContract, Logger } from '@kbn/core/server';
import { RunNowResult, TaskManagerStartContract } from '@kbn/task-manager-plugin/server';
import {
RawAction,
@ -25,6 +25,7 @@ interface CreateExecuteFunctionOptions {
actionTypeRegistry: ActionTypeRegistryContract;
inMemoryConnectors: InMemoryConnector[];
configurationUtilities: ActionsConfigurationUtilities;
logger: Logger;
}
export interface ExecuteOptions
@ -80,6 +81,7 @@ export function createBulkExecutionEnqueuerFunction({
isESOCanEncrypt,
inMemoryConnectors,
configurationUtilities,
logger,
}: CreateExecuteFunctionOptions): BulkExecutionEnqueuer<ExecutionResponse> {
return async function execute(
unsecuredSavedObjectsClient: SavedObjectsClientContract,
@ -113,21 +115,30 @@ export function createBulkExecutionEnqueuerFunction({
inMemoryConnectors,
connectorIds
);
const runnableActions: ExecuteOptions[] = [];
connectors.forEach((c) => {
for (const c of connectors) {
const { id, connector, isInMemory } = c;
validateCanActionBeUsed(connector);
const { actionTypeId, name } = connector;
const { actionTypeId } = connector;
if (!actionTypeRegistry.isActionExecutable(id, actionTypeId, { notifyUsage: true })) {
actionTypeRegistry.ensureActionTypeEnabled(actionTypeId);
try {
validateConnector({ id, connector, actionTypeRegistry });
} catch (e) {
logger.warn(`Skipped the actions for the connector: ${name} (${id}). Error: ${e.message}`);
continue;
}
const actionsWithCurrentConnector = actionsToExecute.filter((action) => action.id === id);
if (actionsWithCurrentConnector.length > 0) {
runnableActions.push(...actionsWithCurrentConnector);
}
actionTypeIds[id] = actionTypeId;
connectorIsInMemory[id] = isInMemory;
});
}
const actions = actionsToExecute.map((actionToExecute) => {
const actions = runnableActions.map((actionToExecute) => {
// Get saved object references from action ID and relatedSavedObjects
const { references, relatedSavedObjectWithRefs } = extractSavedObjectReferences(
actionToExecute.id,
@ -165,6 +176,7 @@ export function createBulkExecutionEnqueuerFunction({
});
const actionTaskParamsRecords: SavedObjectsBulkResponse<ActionTaskParams> =
await unsecuredSavedObjectsClient.bulkCreate(actions, { refresh: false });
const taskInstances = actionTaskParamsRecords.saved_objects.map((so) => {
const actionId = so.attributes.actionId;
return {
@ -177,10 +189,12 @@ export function createBulkExecutionEnqueuerFunction({
scope: ['actions'],
};
});
await taskManager.bulkSchedule(taskInstances);
return {
errors: actionsOverLimit.length > 0,
items: actionsToExecute
items: runnableActions
.map((a) => ({
id: a.id,
actionTypeId: a.actionTypeId,
@ -201,18 +215,15 @@ export function createEphemeralExecutionEnqueuerFunction({
taskManager,
actionTypeRegistry,
inMemoryConnectors,
logger,
}: CreateExecuteFunctionOptions): ExecutionEnqueuer<RunNowResult> {
return async function execute(
unsecuredSavedObjectsClient: SavedObjectsClientContract,
{ id, params, spaceId, source, consumer, apiKey, executionId }: ExecuteOptions
): Promise<RunNowResult> {
const { action } = await getAction(unsecuredSavedObjectsClient, inMemoryConnectors, id);
validateCanActionBeUsed(action);
const { connector } = await getConnector(unsecuredSavedObjectsClient, inMemoryConnectors, id);
const { actionTypeId } = action;
if (!actionTypeRegistry.isActionExecutable(id, actionTypeId, { notifyUsage: true })) {
actionTypeRegistry.ensureActionTypeEnabled(actionTypeId);
}
validateConnector({ id, connector, actionTypeRegistry });
const taskParams: ActionTaskExecutorParams = {
spaceId,
@ -229,7 +240,7 @@ export function createEphemeralExecutionEnqueuerFunction({
};
return taskManager.ephemeralRunNow({
taskType: `actions:${action.actionTypeId}`,
taskType: `actions:${connector.actionTypeId}`,
params: taskParams,
state: {},
scope: ['actions'],
@ -237,13 +248,26 @@ export function createEphemeralExecutionEnqueuerFunction({
};
}
function validateCanActionBeUsed(action: InMemoryConnector | RawAction) {
const { name, isMissingSecrets } = action;
function validateConnector({
id,
connector,
actionTypeRegistry,
}: {
id: string;
connector: InMemoryConnector | RawAction;
actionTypeRegistry: ActionTypeRegistryContract;
}) {
const { name, isMissingSecrets, actionTypeId } = connector;
if (isMissingSecrets) {
throw new Error(
`Unable to execute action because no secrets are defined for the "${name}" connector.`
);
}
if (!actionTypeRegistry.isActionExecutable(id, actionTypeId, { notifyUsage: true })) {
actionTypeRegistry.ensureActionTypeEnabled(actionTypeId);
}
}
function executionSourceAsSavedObjectReferences(executionSource: ActionExecutorOptions['source']) {
@ -259,19 +283,19 @@ function executionSourceAsSavedObjectReferences(executionSource: ActionExecutorO
: {};
}
async function getAction(
async function getConnector(
unsecuredSavedObjectsClient: SavedObjectsClientContract,
inMemoryConnectors: InMemoryConnector[],
actionId: string
): Promise<{ action: InMemoryConnector | RawAction; isInMemory: boolean }> {
): Promise<{ connector: InMemoryConnector | RawAction; isInMemory: boolean }> {
const inMemoryAction = inMemoryConnectors.find((action) => action.id === actionId);
if (inMemoryAction) {
return { action: inMemoryAction, isInMemory: true };
return { connector: inMemoryAction, isInMemory: true };
}
const { attributes } = await unsecuredSavedObjectsClient.get<RawAction>('action', actionId);
return { action: attributes, isInMemory: false };
return { connector: attributes, isInMemory: false };
}
async function getConnectors(

View file

@ -478,6 +478,7 @@ export class ActionsPlugin implements Plugin<PluginSetupContract, PluginStartCon
isESOCanEncrypt: isESOCanEncrypt!,
inMemoryConnectors: this.inMemoryConnectors,
configurationUtilities: actionsConfigUtils,
logger,
}),
bulkExecutionEnqueuer: createBulkExecutionEnqueuerFunction({
taskManager: plugins.taskManager,
@ -485,13 +486,14 @@ export class ActionsPlugin implements Plugin<PluginSetupContract, PluginStartCon
isESOCanEncrypt: isESOCanEncrypt!,
inMemoryConnectors: this.inMemoryConnectors,
configurationUtilities: actionsConfigUtils,
logger,
}),
auditLogger: this.security?.audit.asScoped(request),
usageCounter: this.usageCounter,
connectorTokenClient: new ConnectorTokenClient({
unsecuredSavedObjectsClient,
encryptedSavedObjectsClient,
logger: this.logger,
logger,
}),
async getEventLogClient() {
return plugins.eventLog.getClient(request);
@ -754,6 +756,7 @@ export class ActionsPlugin implements Plugin<PluginSetupContract, PluginStartCon
isESOCanEncrypt: isESOCanEncrypt!,
inMemoryConnectors,
configurationUtilities: actionsConfigUtils,
logger,
}),
bulkExecutionEnqueuer: createBulkExecutionEnqueuerFunction({
taskManager,
@ -761,6 +764,7 @@ export class ActionsPlugin implements Plugin<PluginSetupContract, PluginStartCon
isESOCanEncrypt: isESOCanEncrypt!,
inMemoryConnectors,
configurationUtilities: actionsConfigUtils,
logger,
}),
auditLogger: security?.audit.asScoped(request),
usageCounter,