Skip to content

Commit efe7612

Browse files
authored
[Alerting] Exempt Alerts pre 7.10 from RBAC on their Action execution until updated (#75563)
Marks all Alerts with a `versionApiKeyLastmodified ` field that tracks what version the alert's Api Key was last updated in. We then use this field to exempt legacy alerts (created pre `7.10.0`) in order to use a _dialed down_ version of RBAC which should allow old alerts to continue to function after the upgrade, until they are updates (at which point they will no longer be **Legacy**). More details here: #74858 (comment)
1 parent 599d55a commit efe7612

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+4411
-252
lines changed

x-pack/plugins/actions/README.md

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -280,12 +280,14 @@ The following table describes the properties of the `options` object.
280280
| params | The `params` value to give the action type executor. | object |
281281
| spaceId | The space id the action is within. | string |
282282
| apiKey | The Elasticsearch API key to use for context. (Note: only required and used when security is enabled). | string |
283+
| source | The source of the execution, either an HTTP request or a reference to a Saved Object. | object, optional |
283284

284285
## Example
285286

286287
This example makes action `3c5b2bd4-5424-4e4b-8cf5-c0a58c762cc5` send an email. The action plugin will load the saved object and find what action type to call with `params`.
287288

288289
```typescript
290+
const request: KibanaRequest = { ... };
289291
const actionsClient = await server.plugins.actions.getActionsClientWithRequest(request);
290292
await actionsClient.enqueueExecution({
291293
id: '3c5b2bd4-5424-4e4b-8cf5-c0a58c762cc5',
@@ -296,6 +298,7 @@ await actionsClient.enqueueExecution({
296298
subject: 'My email subject',
297299
body: 'My email body',
298300
},
301+
source: asHttpRequestExecutionSource(request),
299302
});
300303
```
301304

@@ -305,10 +308,11 @@ This api runs the action and asynchronously returns the result of running the ac
305308

306309
The following table describes the properties of the `options` object.
307310

308-
| Property | Description | Type |
309-
| -------- | ---------------------------------------------------- | ------ |
310-
| id | The id of the action you want to execute. | string |
311-
| params | The `params` value to give the action type executor. | object |
311+
| Property | Description | Type |
312+
| -------- | ------------------------------------------------------------------------------------ | ------ |
313+
| id | The id of the action you want to execute. | string |
314+
| params | The `params` value to give the action type executor. | object |
315+
| source | The source of the execution, either an HTTP request or a reference to a Saved Object.| object, optional |
312316

313317
## Example
314318

@@ -324,6 +328,10 @@ const result = await actionsClient.execute({
324328
subject: 'My email subject',
325329
body: 'My email body',
326330
},
331+
source: asSavedObjectExecutionSource({
332+
id: '573891ae-8c48-49cb-a197-0cd5ec34a88b',
333+
type: 'alert'
334+
}),
327335
});
328336
```
329337

x-pack/plugins/actions/server/actions_client.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
} from './create_execute_function';
3131
import { ActionsAuthorization } from './authorization/actions_authorization';
3232
import { ActionType } from '../common';
33+
import { shouldLegacyRbacApplyBySource } from './authorization/should_legacy_rbac_apply_by_source';
3334

3435
// We are assuming there won't be many actions. This is why we will load
3536
// all the actions in advance and assume the total count to not go over 10000.
@@ -298,13 +299,19 @@ export class ActionsClient {
298299
public async execute({
299300
actionId,
300301
params,
302+
source,
301303
}: Omit<ExecuteOptions, 'request'>): Promise<ActionTypeExecutorResult<unknown>> {
302-
await this.authorization.ensureAuthorized('execute');
303-
return this.actionExecutor.execute({ actionId, params, request: this.request });
304+
if (!(await shouldLegacyRbacApplyBySource(this.unsecuredSavedObjectsClient, source))) {
305+
await this.authorization.ensureAuthorized('execute');
306+
}
307+
return this.actionExecutor.execute({ actionId, params, source, request: this.request });
304308
}
305309

306310
public async enqueueExecution(options: EnqueueExecutionOptions): Promise<void> {
307-
await this.authorization.ensureAuthorized('execute');
311+
const { source } = options;
312+
if (!(await shouldLegacyRbacApplyBySource(this.unsecuredSavedObjectsClient, source))) {
313+
await this.authorization.ensureAuthorized('execute');
314+
}
308315
return this.executionEnqueuer(this.unsecuredSavedObjectsClient, options);
309316
}
310317

x-pack/plugins/actions/server/authorization/actions_authorization.test.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { ActionsAuthorization } from './actions_authorization';
99
import { actionsAuthorizationAuditLoggerMock } from './audit_logger.mock';
1010
import { ActionsAuthorizationAuditLogger, AuthorizationResult } from './audit_logger';
1111
import { ACTION_SAVED_OBJECT_TYPE, ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE } from '../saved_objects';
12+
import { AuthenticatedUser } from '../../../security/server';
1213

1314
const request = {} as KibanaRequest;
1415

@@ -19,12 +20,13 @@ const mockAuthorizationAction = (type: string, operation: string) => `${type}/${
1920
function mockSecurity() {
2021
const security = securityMock.createSetup();
2122
const authorization = security.authz;
23+
const authentication = security.authc;
2224
// typescript is having trouble inferring jest's automocking
2325
(authorization.actions.savedObject.get as jest.MockedFunction<
2426
typeof authorization.actions.savedObject.get
2527
>).mockImplementation(mockAuthorizationAction);
2628
authorization.mode.useRbacForRequest.mockReturnValue(true);
27-
return { authorization };
29+
return { authorization, authentication };
2830
}
2931

3032
beforeEach(() => {
@@ -192,4 +194,38 @@ describe('ensureAuthorized', () => {
192194
]
193195
`);
194196
});
197+
198+
test('exempts users from requiring privileges to execute actions when shouldUseLegacyRbac is true', async () => {
199+
const { authorization, authentication } = mockSecurity();
200+
const checkPrivileges: jest.MockedFunction<ReturnType<
201+
typeof authorization.checkPrivilegesDynamicallyWithRequest
202+
>> = jest.fn();
203+
authorization.checkPrivilegesDynamicallyWithRequest.mockReturnValue(checkPrivileges);
204+
const actionsAuthorization = new ActionsAuthorization({
205+
request,
206+
authorization,
207+
authentication,
208+
auditLogger,
209+
shouldUseLegacyRbac: true,
210+
});
211+
212+
authentication.getCurrentUser.mockReturnValueOnce(({
213+
username: 'some-user',
214+
} as unknown) as AuthenticatedUser);
215+
216+
await actionsAuthorization.ensureAuthorized('execute', 'myType');
217+
218+
expect(authorization.actions.savedObject.get).not.toHaveBeenCalled();
219+
expect(checkPrivileges).not.toHaveBeenCalled();
220+
221+
expect(auditLogger.actionsAuthorizationSuccess).toHaveBeenCalledTimes(1);
222+
expect(auditLogger.actionsAuthorizationFailure).not.toHaveBeenCalled();
223+
expect(auditLogger.actionsAuthorizationSuccess.mock.calls[0]).toMatchInlineSnapshot(`
224+
Array [
225+
"some-user",
226+
"execute",
227+
"myType",
228+
]
229+
`);
230+
});
195231
});

x-pack/plugins/actions/server/authorization/actions_authorization.ts

Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,15 @@ export interface ConstructorOptions {
1414
request: KibanaRequest;
1515
auditLogger: ActionsAuthorizationAuditLogger;
1616
authorization?: SecurityPluginSetup['authz'];
17+
authentication?: SecurityPluginSetup['authc'];
18+
// In order to support legacy Alerts which predate the introduction of the
19+
// Actions feature in Kibana we need a way of "dialing down" the level of
20+
// authorization for certain opearations.
21+
// Specifically, we want to allow these old alerts and their scheduled
22+
// actions to continue to execute - which requires that we exempt auth on
23+
// `get` for Connectors and `execute` for Action execution when used by
24+
// these legacy alerts
25+
shouldUseLegacyRbac?: boolean;
1726
}
1827

1928
const operationAlias: Record<
@@ -27,33 +36,57 @@ const operationAlias: Record<
2736
list: (authorization) => authorization.actions.savedObject.get(ACTION_SAVED_OBJECT_TYPE, 'find'),
2837
};
2938

39+
const LEGACY_RBAC_EXEMPT_OPERATIONS = new Set(['get', 'execute']);
40+
3041
export class ActionsAuthorization {
3142
private readonly request: KibanaRequest;
3243
private readonly authorization?: SecurityPluginSetup['authz'];
44+
private readonly authentication?: SecurityPluginSetup['authc'];
3345
private readonly auditLogger: ActionsAuthorizationAuditLogger;
46+
private readonly shouldUseLegacyRbac: boolean;
3447

35-
constructor({ request, authorization, auditLogger }: ConstructorOptions) {
48+
constructor({
49+
request,
50+
authorization,
51+
authentication,
52+
auditLogger,
53+
shouldUseLegacyRbac = false,
54+
}: ConstructorOptions) {
3655
this.request = request;
3756
this.authorization = authorization;
57+
this.authentication = authentication;
3858
this.auditLogger = auditLogger;
59+
this.shouldUseLegacyRbac = shouldUseLegacyRbac;
3960
}
4061

4162
public async ensureAuthorized(operation: string, actionTypeId?: string) {
4263
const { authorization } = this;
4364
if (authorization?.mode?.useRbacForRequest(this.request)) {
44-
const checkPrivileges = authorization.checkPrivilegesDynamicallyWithRequest(this.request);
45-
const { hasAllRequested, username } = await checkPrivileges({
46-
kibana: operationAlias[operation]
47-
? operationAlias[operation](authorization)
48-
: authorization.actions.savedObject.get(ACTION_SAVED_OBJECT_TYPE, operation),
49-
});
50-
if (hasAllRequested) {
51-
this.auditLogger.actionsAuthorizationSuccess(username, operation, actionTypeId);
52-
} else {
53-
throw Boom.forbidden(
54-
this.auditLogger.actionsAuthorizationFailure(username, operation, actionTypeId)
65+
if (this.isOperationExemptDueToLegacyRbac(operation)) {
66+
this.auditLogger.actionsAuthorizationSuccess(
67+
this.authentication?.getCurrentUser(this.request)?.username ?? '',
68+
operation,
69+
actionTypeId
5570
);
71+
} else {
72+
const checkPrivileges = authorization.checkPrivilegesDynamicallyWithRequest(this.request);
73+
const { hasAllRequested, username } = await checkPrivileges({
74+
kibana: operationAlias[operation]
75+
? operationAlias[operation](authorization)
76+
: authorization.actions.savedObject.get(ACTION_SAVED_OBJECT_TYPE, operation),
77+
});
78+
if (hasAllRequested) {
79+
this.auditLogger.actionsAuthorizationSuccess(username, operation, actionTypeId);
80+
} else {
81+
throw Boom.forbidden(
82+
this.auditLogger.actionsAuthorizationFailure(username, operation, actionTypeId)
83+
);
84+
}
5685
}
5786
}
5887
}
88+
89+
private isOperationExemptDueToLegacyRbac(operation: string) {
90+
return this.shouldUseLegacyRbac && LEGACY_RBAC_EXEMPT_OPERATIONS.has(operation);
91+
}
5992
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License;
4+
* you may not use this file except in compliance with the Elastic License.
5+
*/
6+
import { shouldLegacyRbacApplyBySource } from './should_legacy_rbac_apply_by_source';
7+
import { savedObjectsClientMock } from '../../../../../src/core/server/mocks';
8+
import uuid from 'uuid';
9+
import { asSavedObjectExecutionSource } from '../lib';
10+
11+
const unsecuredSavedObjectsClient = savedObjectsClientMock.create();
12+
13+
describe(`#shouldLegacyRbacApplyBySource`, () => {
14+
test('should return false if no source is provided', async () => {
15+
expect(await shouldLegacyRbacApplyBySource(unsecuredSavedObjectsClient)).toEqual(false);
16+
});
17+
18+
test('should return false if source is not an alert', async () => {
19+
expect(
20+
await shouldLegacyRbacApplyBySource(
21+
unsecuredSavedObjectsClient,
22+
asSavedObjectExecutionSource({
23+
type: 'action',
24+
id: uuid.v4(),
25+
})
26+
)
27+
).toEqual(false);
28+
});
29+
30+
test('should return false if source alert is not marked as legacy', async () => {
31+
const id = uuid.v4();
32+
unsecuredSavedObjectsClient.get.mockResolvedValue(mockAlert({ id }));
33+
expect(
34+
await shouldLegacyRbacApplyBySource(
35+
unsecuredSavedObjectsClient,
36+
asSavedObjectExecutionSource({
37+
type: 'alert',
38+
id,
39+
})
40+
)
41+
).toEqual(false);
42+
});
43+
44+
test('should return true if source alert is marked as legacy', async () => {
45+
const id = uuid.v4();
46+
unsecuredSavedObjectsClient.get.mockResolvedValue(
47+
mockAlert({ id, attributes: { meta: { versionApiKeyLastmodified: 'pre-7.10.0' } } })
48+
);
49+
expect(
50+
await shouldLegacyRbacApplyBySource(
51+
unsecuredSavedObjectsClient,
52+
asSavedObjectExecutionSource({
53+
type: 'alert',
54+
id,
55+
})
56+
)
57+
).toEqual(true);
58+
});
59+
60+
test('should return false if source alert is marked as modern', async () => {
61+
const id = uuid.v4();
62+
unsecuredSavedObjectsClient.get.mockResolvedValue(
63+
mockAlert({ id, attributes: { meta: { versionApiKeyLastmodified: '7.10.0' } } })
64+
);
65+
expect(
66+
await shouldLegacyRbacApplyBySource(
67+
unsecuredSavedObjectsClient,
68+
asSavedObjectExecutionSource({
69+
type: 'alert',
70+
id,
71+
})
72+
)
73+
).toEqual(false);
74+
});
75+
76+
test('should return false if source alert is marked with a last modified version', async () => {
77+
const id = uuid.v4();
78+
unsecuredSavedObjectsClient.get.mockResolvedValue(mockAlert({ id, attributes: { meta: {} } }));
79+
expect(
80+
await shouldLegacyRbacApplyBySource(
81+
unsecuredSavedObjectsClient,
82+
asSavedObjectExecutionSource({
83+
type: 'alert',
84+
id,
85+
})
86+
)
87+
).toEqual(false);
88+
});
89+
});
90+
91+
const mockAlert = (overrides: Record<string, unknown> = {}) => ({
92+
id: '1',
93+
type: 'alert',
94+
attributes: {
95+
consumer: 'myApp',
96+
schedule: { interval: '10s' },
97+
alertTypeId: 'myType',
98+
enabled: false,
99+
actions: [
100+
{
101+
group: 'default',
102+
id: '1',
103+
actionTypeId: '1',
104+
actionRef: '1',
105+
params: {
106+
foo: true,
107+
},
108+
},
109+
],
110+
},
111+
version: '123',
112+
references: [],
113+
...overrides,
114+
});
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License;
4+
* you may not use this file except in compliance with the Elastic License.
5+
*/
6+
7+
import { SavedObjectsClientContract } from 'src/core/server';
8+
import { ActionExecutionSource, isSavedObjectExecutionSource } from '../lib';
9+
import { ALERT_SAVED_OBJECT_TYPE } from '../saved_objects';
10+
11+
const LEGACY_VERSION = 'pre-7.10.0';
12+
13+
export async function shouldLegacyRbacApplyBySource(
14+
unsecuredSavedObjectsClient: SavedObjectsClientContract,
15+
executionSource?: ActionExecutionSource<unknown>
16+
): Promise<boolean> {
17+
return isSavedObjectExecutionSource(executionSource) &&
18+
executionSource?.source?.type === ALERT_SAVED_OBJECT_TYPE
19+
? (
20+
await unsecuredSavedObjectsClient.get<{
21+
meta?: {
22+
versionApiKeyLastmodified?: string;
23+
};
24+
}>(ALERT_SAVED_OBJECT_TYPE, executionSource.source.id)
25+
).attributes.meta?.versionApiKeyLastmodified === LEGACY_VERSION
26+
: false;
27+
}

0 commit comments

Comments
 (0)