forked from elastic/kibana
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
WIP [ResponseOps]: Refactor alerting task runner - combine loadRuleAt…
…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
Showing
3 changed files
with
395 additions
and
110 deletions.
There are no files selected for viewing
232 changes: 232 additions & 0 deletions
232
x-pack/plugins/alerting/server/task_runner/rule_loader.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
141
x-pack/plugins/alerting/server/task_runner/rule_loader.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
Oops, something went wrong.