Skip to content

Commit

Permalink
WIP [ResponseOps]: Refactor alerting task runner - combine loadRuleAt…
Browse files Browse the repository at this point in the history
…tributesAndRun() and validateAndExecuteRule()

resolves elastic#131544

Extract the `loadRuleAttributesAndRun()` and `validateAndExecuteRule()`
methods from the alerting task manager, into a separate module.

meta issue: elastic#124206
  • Loading branch information
pmuellr committed May 19, 2022
1 parent d8a6258 commit ae28ddc
Show file tree
Hide file tree
Showing 3 changed files with 395 additions and 110 deletions.
232 changes: 232 additions & 0 deletions x-pack/plugins/alerting/server/task_runner/rule_loader.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
/*
* 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 { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks';
import { KibanaRequest } from '@kbn/core/server';

import { getDecryptedAttributes, getFakeKibanaRequest } from './rule_loader';
import { TaskRunnerContext } from './task_runner_factory';

const encryptedSavedObjects = encryptedSavedObjectsMock.createClient();

function spaceIdToNamespace(spaceId: string): string | undefined {
return spaceId === 'default' ? undefined : spaceId;
}

describe('rule_loader', () => {
beforeEach(() => {
jest.resetAllMocks();
});

describe('loadRule()', () => {
test('succeeds in the happy path', async () => {
expect(true).toBe(false);
});

test('throws when cannot decrypt attributes', async () => { expect(true).toBe(false); });

test('throws when rule is not enabled', async () => { expect(true).toBe(false); });

test('throws when user cannot read rule', async () => { expect(true).toBe(false); });

test('throws when rule type is not enabled', async () => { expect(true).toBe(false); });

test('throws when rule params fail validation', async () => { expect(true).toBe(false); });
});

describe('getDecryptedAttributes()', () => {
const apiKey = 'rule-apikey';
const ruleId = 'rule-id-1';
const enabled = true;
const consumer = 'rule-consumer';
const namespace = 'rule-namespace';

const context = {
spaceIdToNamespace: spaceIdToNamespace,
encryptedSavedObjectsClient: encryptedSavedObjects,
};
const castedContext = context as unknown as TaskRunnerContext;

beforeEach(() => {
context.spaceIdToNamespace = jest.fn(spaceIdToNamespace);
})

test('succeeds with default space', async () => {
encryptedSavedObjects.getDecryptedAsInternalUser.mockImplementation(
async (type, id, opts) => {
return { id, type, references: [], attributes: { apiKey, enabled, consumer } };
}
);

const mockSpaceIdToNamespace = jest.spyOn(context, 'spaceIdToNamespace');
const result = await getDecryptedAttributes(castedContext, ruleId, 'default');

expect(result.apiKey).toBe(apiKey);
expect(result.consumer).toBe(consumer);
expect(result.enabled).toBe(true);

const s2nArgs = mockSpaceIdToNamespace.mock.calls[0];
expect(s2nArgs[0]).toBe('default');

const esoArgs = encryptedSavedObjects.getDecryptedAsInternalUser.mock.calls[0];
expect(esoArgs[0]).toBe('alert');
expect(esoArgs[1]).toBe(ruleId);
expect(esoArgs[2]).toEqual({ namespace: undefined });
});

test('succeeds with non-default space', async () => {
encryptedSavedObjects.getDecryptedAsInternalUser.mockImplementation(
async (type, id, opts) => {
return { id, type, references: [], attributes: { apiKey, enabled, consumer } };
}
);

const mockSpaceIdToNamespace = jest.spyOn(context, 'spaceIdToNamespace');
const result = await getDecryptedAttributes(castedContext, ruleId, namespace);

expect(result.apiKey).toBe(apiKey);
expect(result.consumer).toBe(consumer);
expect(result.enabled).toBe(true);

const s2nArgs = mockSpaceIdToNamespace.mock.calls[0];
expect(s2nArgs[0]).toBe(namespace);

const esoArgs = encryptedSavedObjects.getDecryptedAsInternalUser.mock.calls[0];
expect(esoArgs[0]).toBe('alert');
expect(esoArgs[1]).toBe(ruleId);
expect(esoArgs[2]).toEqual({ namespace });
});

test('fails', async () => {
encryptedSavedObjects.getDecryptedAsInternalUser.mockImplementation(
async (type, id, opts) => {
throw new Error('wops');
}
);

function spaceToNamespace(spaceId?: string): string | undefined {
return spaceId == null ? 'default' : spaceId;
}

const context = {
spaceIdToNamespace: jest.fn(spaceToNamespace),
encryptedSavedObjectsClient: encryptedSavedObjects,
};
const castedContext = context as unknown as TaskRunnerContext;

await expect(getDecryptedAttributes(castedContext, ruleId, namespace)).rejects.toThrow(
'wops'
);
});
});

describe('getFakeKibanaRequest()', () => {
const context = {
basePathService: { set: jest.fn() },
};
const castedContext = context as unknown as TaskRunnerContext;
const spaceId = 'rule-spaceId';
const apiKey = 'rule-apikey';

test('has API key, in default space', async () => {
const kibanaRequestFromMock = jest.spyOn(KibanaRequest, 'from');
const fakeRequest = getFakeKibanaRequest(castedContext, 'default', apiKey);

const bpsSetParams = context.basePathService.set.mock.calls[0];
expect(bpsSetParams[0]).toBe(fakeRequest);
expect(bpsSetParams[1]).toBe('/');

expect(fakeRequest).toEqual(expect.any(KibanaRequest));
expect(kibanaRequestFromMock.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"headers": Object {
"authorization": "ApiKey rule-apikey",
},
"path": "/",
"raw": Object {
"req": Object {
"url": "/",
},
},
"route": Object {
"settings": Object {},
},
"url": Object {
"href": "/",
},
},
]
`);
kibanaRequestFromMock.mockRestore();
});

test('has API key, in non-default space', async () => {
const kibanaRequestFromMock = jest.spyOn(KibanaRequest, 'from');
const fakeRequest = getFakeKibanaRequest(castedContext, spaceId, apiKey);

const bpsSetParams = context.basePathService.set.mock.calls[0];
expect(bpsSetParams[0]).toBe(fakeRequest);
expect(bpsSetParams[1]).toBe('/s/rule-spaceId');

expect(fakeRequest).toEqual(expect.any(KibanaRequest));
expect(kibanaRequestFromMock.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"headers": Object {
"authorization": "ApiKey rule-apikey",
},
"path": "/",
"raw": Object {
"req": Object {
"url": "/",
},
},
"route": Object {
"settings": Object {},
},
"url": Object {
"href": "/",
},
},
]
`);
kibanaRequestFromMock.mockRestore();
});

test('does not have API key, in default space', async () => {
const kibanaRequestFromMock = jest.spyOn(KibanaRequest, 'from');
const fakeRequest = getFakeKibanaRequest(castedContext, 'default', null);

const bpsSetParams = context.basePathService.set.mock.calls[0];
expect(bpsSetParams[0]).toBe(fakeRequest);
expect(bpsSetParams[1]).toBe('/');

expect(fakeRequest).toEqual(expect.any(KibanaRequest));
expect(kibanaRequestFromMock.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"headers": Object {},
"path": "/",
"raw": Object {
"req": Object {
"url": "/",
},
},
"route": Object {
"settings": Object {},
},
"url": Object {
"href": "/",
},
},
]
`);
kibanaRequestFromMock.mockRestore();
});
});
});
141 changes: 141 additions & 0 deletions x-pack/plugins/alerting/server/task_runner/rule_loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/*
* 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 type { Request } from '@hapi/hapi';
import { addSpaceIdToPath } from '@kbn/spaces-plugin/server';
import { KibanaRequest } from '@kbn/core/server';
import { TaskRunnerContext } from './task_runner_factory';
import { ErrorWithReason, validateRuleTypeParams } from '../lib';
import {
RuleExecutionStatusErrorReasons,
RawRule,
RuleTypeRegistry,
RuleTypeParamsValidator,
SanitizedRule,
} from '../types';
import { MONITORING_HISTORY_LIMIT, RuleTypeParams } from '../../common';
import { AlertingEventLogger } from '../lib/alerting_event_logger/alerting_event_logger';

export interface LoadRuleParams<Params extends RuleTypeParams> {
paramValidator?: RuleTypeParamsValidator<Params>;
ruleId: string;
spaceId: string;
context: TaskRunnerContext;
ruleTypeRegistry: RuleTypeRegistry;
alertingEventLogger: AlertingEventLogger;
}

export async function loadRule<Params extends RuleTypeParams>(params: LoadRuleParams<Params>) {
const { paramValidator, ruleId, spaceId, context, ruleTypeRegistry, alertingEventLogger } =
params;
let enabled: boolean;
let apiKey: string | null;

try {
const decryptedAttributes = await getDecryptedAttributes(context, ruleId, spaceId);
apiKey = decryptedAttributes.apiKey;
enabled = decryptedAttributes.enabled;
} catch (err) {
throw new ErrorWithReason(RuleExecutionStatusErrorReasons.Decrypt, err);
}

if (!enabled) {
throw new ErrorWithReason(
RuleExecutionStatusErrorReasons.Disabled,
new Error(`Rule failed to execute because rule ran after it was disabled.`)
);
}

const fakeRequest = getFakeKibanaRequest(context, spaceId, apiKey);
const rulesClient = context.getRulesClientWithRequest(fakeRequest);

let rule: SanitizedRule<Params>;

// Ensure API key is still valid and user has access
try {
rule = await rulesClient.get<Params>({ id: ruleId });
} catch (err) {
throw new ErrorWithReason(RuleExecutionStatusErrorReasons.Read, err);
}

alertingEventLogger.setRuleName(rule.name);

try {
ruleTypeRegistry.ensureRuleTypeEnabled(rule.alertTypeId);
} catch (err) {
throw new ErrorWithReason(RuleExecutionStatusErrorReasons.License, err);
}

const validatedParams = validateRuleTypeParams<Params>(rule.params, paramValidator);

if (rule.monitoring) {
if (rule.monitoring.execution.history.length >= MONITORING_HISTORY_LIMIT) {
// Remove the first (oldest) record
rule.monitoring.execution.history.shift();
}
}

return {
rule,
fakeRequest,
apiKey,
rulesClient,
validatedParams,
};
}

export async function getDecryptedAttributes(
context: TaskRunnerContext,
ruleId: string,
spaceId: string
): Promise<{ apiKey: string | null; enabled: boolean; consumer: string }> {
const namespace = context.spaceIdToNamespace(spaceId);

// Only fetch encrypted attributes here, we'll create a saved objects client
// scoped with the API key to fetch the remaining data.
const {
attributes: { apiKey, enabled, consumer },
} = await context.encryptedSavedObjectsClient.getDecryptedAsInternalUser<RawRule>(
'alert',
ruleId,
{ namespace }
);

return { apiKey, enabled, consumer };
}

export function getFakeKibanaRequest(
context: TaskRunnerContext,
spaceId: string,
apiKey: RawRule['apiKey']
) {
const requestHeaders: Record<string, string> = {};

if (apiKey) {
requestHeaders.authorization = `ApiKey ${apiKey}`;
}

const path = addSpaceIdToPath('/', spaceId);

const fakeRequest = KibanaRequest.from({
headers: requestHeaders,
path: '/',
route: { settings: {} },
url: {
href: '/',
},
raw: {
req: {
url: '/',
},
},
} as unknown as Request);

context.basePathService.set(fakeRequest, path);

return fakeRequest;
}
Loading

0 comments on commit ae28ddc

Please sign in to comment.