[Actions] System actions authorization (#161341)

## Summary

This PR adds the ability for system actions to be able to define their
own Kibana privileges that need to be authorized before execution.

Depends on: https://github.com/elastic/kibana/pull/160983

### Checklist

Delete any items that are not applicable to this PR.

- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

### For maintainers

- [x] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

---------

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Christos Nasikas 2023-07-20 13:41:56 +03:00 committed by GitHub
parent c1fc644fbf
commit 2943fc9e06
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
45 changed files with 1631 additions and 127 deletions

View file

@ -19,6 +19,7 @@ const createActionTypeRegistryMock = () => {
isActionExecutable: jest.fn(),
isSystemActionType: jest.fn(),
getUtils: jest.fn(),
getSystemActionKibanaPrivileges: jest.fn(),
};
return mocked;
};

View file

@ -248,6 +248,29 @@ describe('actionTypeRegistry', () => {
})
).not.toThrow();
});
test('throws if the kibana privileges are defined but the action type is not a system action type', () => {
const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams);
expect(() =>
actionTypeRegistry.register({
id: 'my-action-type',
name: 'My action type',
minimumLicenseRequired: 'basic',
supportedFeatureIds: ['alerting'],
getKibanaPrivileges: jest.fn(),
isSystemActionType: false,
validate: {
config: { schema: schema.object({}) },
secrets: { schema: schema.object({}) },
params: { schema: schema.object({}) },
},
executor,
})
).toThrowErrorMatchingInlineSnapshot(
`"Kibana privilege authorization is only supported for system action types"`
);
});
});
describe('get()', () => {
@ -691,4 +714,92 @@ describe('actionTypeRegistry', () => {
expect(result).toBe(false);
});
});
describe('getSystemActionKibanaPrivileges()', () => {
it('should get the kibana privileges correctly for system actions', () => {
const registry = new ActionTypeRegistry(actionTypeRegistryParams);
registry.register({
id: '.cases',
name: 'Cases',
minimumLicenseRequired: 'platinum',
supportedFeatureIds: ['alerting'],
getKibanaPrivileges: () => ['test/create'],
validate: {
config: { schema: schema.object({}) },
secrets: { schema: schema.object({}) },
params: { schema: schema.object({}) },
},
isSystemActionType: true,
executor,
});
const result = registry.getSystemActionKibanaPrivileges('.cases');
expect(result).toEqual(['test/create']);
});
it('should return an empty array if the system action does not define any kibana privileges', () => {
const registry = new ActionTypeRegistry(actionTypeRegistryParams);
registry.register({
id: '.cases',
name: 'Cases',
minimumLicenseRequired: 'platinum',
supportedFeatureIds: ['alerting'],
validate: {
config: { schema: schema.object({}) },
secrets: { schema: schema.object({}) },
params: { schema: schema.object({}) },
},
isSystemActionType: true,
executor,
});
const result = registry.getSystemActionKibanaPrivileges('.cases');
expect(result).toEqual([]);
});
it('should return an empty array if the action type is not a system action', () => {
const registry = new ActionTypeRegistry(actionTypeRegistryParams);
registry.register({
id: 'foo',
name: 'Foo',
minimumLicenseRequired: 'basic',
supportedFeatureIds: ['alerting'],
validate: {
config: { schema: schema.object({}) },
secrets: { schema: schema.object({}) },
params: { schema: schema.object({}) },
},
executor,
});
const result = registry.getSystemActionKibanaPrivileges('foo');
expect(result).toEqual([]);
});
it('should pass the params correctly', () => {
const registry = new ActionTypeRegistry(actionTypeRegistryParams);
const getKibanaPrivileges = jest.fn().mockReturnValue(['test/create']);
registry.register({
id: '.cases',
name: 'Cases',
minimumLicenseRequired: 'platinum',
supportedFeatureIds: ['alerting'],
getKibanaPrivileges,
validate: {
config: { schema: schema.object({}) },
secrets: { schema: schema.object({}) },
params: { schema: schema.object({}) },
},
isSystemActionType: true,
executor,
});
registry.getSystemActionKibanaPrivileges('.cases', { foo: 'bar' });
expect(getKibanaPrivileges).toHaveBeenCalledWith({ params: { foo: 'bar' } });
});
});
});

View file

@ -101,6 +101,22 @@ export class ActionTypeRegistry {
public isSystemActionType = (actionTypeId: string): boolean =>
Boolean(this.actionTypes.get(actionTypeId)?.isSystemActionType);
/**
* Returns the kibana privileges of a system action type
*/
public getSystemActionKibanaPrivileges<Params extends ActionTypeParams = ActionTypeParams>(
actionTypeId: string,
params?: Params
): string[] {
const actionType = this.actionTypes.get(actionTypeId);
if (!actionType?.isSystemActionType) {
return [];
}
return actionType?.getKibanaPrivileges?.({ params }) ?? [];
}
/**
* Registers an action type to the action type registry
*/
@ -148,6 +164,15 @@ export class ActionTypeRegistry {
);
}
if (!actionType.isSystemActionType && actionType.getKibanaPrivileges) {
throw new Error(
i18n.translate('xpack.actions.actionTypeRegistry.register.invalidKibanaPrivileges', {
defaultMessage:
'Kibana privilege authorization is only supported for system action types',
})
);
}
const maxAttempts = this.actionsConfigUtils.getMaxAttempts({
actionTypeId: actionType.id,
actionTypeMaxAttempts: actionType.maxAttempts,

View file

@ -198,7 +198,10 @@ describe('create()', () => {
},
});
expect(authorization.ensureAuthorized).toHaveBeenCalledWith('create', 'my-action-type');
expect(authorization.ensureAuthorized).toHaveBeenCalledWith({
operation: 'create',
actionTypeId: 'my-action-type',
});
});
test('throws when user is not authorised to create this type of action', async () => {
@ -242,7 +245,10 @@ describe('create()', () => {
})
).rejects.toMatchInlineSnapshot(`[Error: Unauthorized to create a "my-action-type" action]`);
expect(authorization.ensureAuthorized).toHaveBeenCalledWith('create', 'my-action-type');
expect(authorization.ensureAuthorized).toHaveBeenCalledWith({
operation: 'create',
actionTypeId: 'my-action-type',
});
});
});
@ -847,7 +853,7 @@ describe('get()', () => {
await actionsClient.get({ id: '1' });
expect(authorization.ensureAuthorized).toHaveBeenCalledWith('get');
expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ operation: 'get' });
});
test('ensures user is authorised to get preconfigured type of action', async () => {
@ -885,7 +891,7 @@ describe('get()', () => {
await actionsClient.get({ id: 'testPreconfigured' });
expect(authorization.ensureAuthorized).toHaveBeenCalledWith('get');
expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ operation: 'get' });
});
test('ensures user is authorised to get a system action', async () => {
@ -919,7 +925,7 @@ describe('get()', () => {
await actionsClient.get({ id: 'system-connector-.cases' });
expect(authorization.ensureAuthorized).toHaveBeenCalledWith('get');
expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ operation: 'get' });
});
test('throws when user is not authorised to get the type of action', async () => {
@ -943,7 +949,7 @@ describe('get()', () => {
`[Error: Unauthorized to get a "my-action-type" action]`
);
expect(authorization.ensureAuthorized).toHaveBeenCalledWith('get');
expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ operation: 'get' });
});
test('throws when user is not authorised to get preconfigured of action', async () => {
@ -987,7 +993,7 @@ describe('get()', () => {
`[Error: Unauthorized to get a "my-action-type" action]`
);
expect(authorization.ensureAuthorized).toHaveBeenCalledWith('get');
expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ operation: 'get' });
});
test('throws when user is not authorised to get a system action', async () => {
@ -1029,7 +1035,7 @@ describe('get()', () => {
`[Error: Unauthorized to get a "system-connector-.cases" action]`
);
expect(authorization.ensureAuthorized).toHaveBeenCalledWith('get');
expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ operation: 'get' });
});
});
@ -1270,7 +1276,7 @@ describe('getAll()', () => {
test('ensures user is authorised to get the type of action', async () => {
await getAllOperation();
expect(authorization.ensureAuthorized).toHaveBeenCalledWith('get');
expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ operation: 'get' });
});
test('throws when user is not authorised to create the type of action', async () => {
@ -1282,7 +1288,7 @@ describe('getAll()', () => {
`[Error: Unauthorized to get all actions]`
);
expect(authorization.ensureAuthorized).toHaveBeenCalledWith('get');
expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ operation: 'get' });
});
});
@ -1521,7 +1527,7 @@ describe('getBulk()', () => {
test('ensures user is authorised to get the type of action', async () => {
await getBulkOperation();
expect(authorization.ensureAuthorized).toHaveBeenCalledWith('get');
expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ operation: 'get' });
});
test('throws when user is not authorised to create the type of action', async () => {
@ -1533,7 +1539,7 @@ describe('getBulk()', () => {
`[Error: Unauthorized to get all actions]`
);
expect(authorization.ensureAuthorized).toHaveBeenCalledWith('get');
expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ operation: 'get' });
});
});
@ -1762,7 +1768,7 @@ describe('getOAuthAccessToken()', () => {
},
},
});
expect(authorization.ensureAuthorized).toHaveBeenCalledWith('update');
expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ operation: 'update' });
});
test('throws when user is not authorised to create the type of action', async () => {
@ -1786,7 +1792,7 @@ describe('getOAuthAccessToken()', () => {
})
).rejects.toMatchInlineSnapshot(`[Error: Unauthorized to update actions]`);
expect(authorization.ensureAuthorized).toHaveBeenCalledWith('update');
expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ operation: 'update' });
});
});
@ -1809,7 +1815,7 @@ describe('getOAuthAccessToken()', () => {
})
).rejects.toMatchInlineSnapshot(`[Error: Token URL must use http or https]`);
expect(authorization.ensureAuthorized).toHaveBeenCalledWith('update');
expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ operation: 'update' });
});
test('throws when tokenUrl does not contain hostname', async () => {
@ -1831,7 +1837,7 @@ describe('getOAuthAccessToken()', () => {
})
).rejects.toMatchInlineSnapshot(`[Error: Token URL must contain hostname]`);
expect(authorization.ensureAuthorized).toHaveBeenCalledWith('update');
expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ operation: 'update' });
});
test('throws when tokenUrl is not in allowed hosts', async () => {
@ -1857,7 +1863,7 @@ describe('getOAuthAccessToken()', () => {
})
).rejects.toMatchInlineSnapshot(`[Error: URI not allowed]`);
expect(authorization.ensureAuthorized).toHaveBeenCalledWith('update');
expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ operation: 'update' });
expect(configurationUtilities.ensureUriAllowed).toHaveBeenCalledWith(
`https://testurl.service-now.com/oauth_token.do`
);
@ -2003,7 +2009,7 @@ describe('delete()', () => {
describe('authorization', () => {
test('ensures user is authorised to delete actions', async () => {
await actionsClient.delete({ id: '1' });
expect(authorization.ensureAuthorized).toHaveBeenCalledWith('delete');
expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ operation: 'delete' });
});
test('throws when user is not authorised to create the type of action', async () => {
@ -2015,7 +2021,7 @@ describe('delete()', () => {
`[Error: Unauthorized to delete all actions]`
);
expect(authorization.ensureAuthorized).toHaveBeenCalledWith('delete');
expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ operation: 'delete' });
});
test(`deletes any existing authorization tokens`, async () => {
@ -2205,7 +2211,7 @@ describe('update()', () => {
describe('authorization', () => {
test('ensures user is authorised to update actions', async () => {
await updateOperation();
expect(authorization.ensureAuthorized).toHaveBeenCalledWith('update');
expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ operation: 'update' });
});
test('throws when user is not authorised to create the type of action', async () => {
@ -2217,7 +2223,7 @@ describe('update()', () => {
`[Error: Unauthorized to update all actions]`
);
expect(authorization.ensureAuthorized).toHaveBeenCalledWith('update');
expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ operation: 'update' });
});
test(`deletes any existing authorization tokens`, async () => {
@ -2737,7 +2743,10 @@ describe('execute()', () => {
},
source: asHttpRequestExecutionSource(request),
});
expect(authorization.ensureAuthorized).toHaveBeenCalledWith('execute');
expect(authorization.ensureAuthorized).toHaveBeenCalledWith({
operation: 'execute',
additionalPrivileges: [],
});
});
test('throws when user is not authorised to create the type of action', async () => {
@ -2758,7 +2767,10 @@ describe('execute()', () => {
})
).rejects.toMatchInlineSnapshot(`[Error: Unauthorized to execute all actions]`);
expect(authorization.ensureAuthorized).toHaveBeenCalledWith('execute');
expect(authorization.ensureAuthorized).toHaveBeenCalledWith({
operation: 'execute',
additionalPrivileges: [],
});
});
test('tracks legacy RBAC', async () => {
@ -2775,6 +2787,200 @@ describe('execute()', () => {
});
expect(trackLegacyRBACExemption as jest.Mock).toBeCalledWith('execute', mockUsageCounter);
expect(authorization.ensureAuthorized).not.toHaveBeenCalled();
});
test('ensures that system actions privileges are being authorized correctly', async () => {
(getAuthorizationModeBySource as jest.Mock).mockImplementationOnce(() => {
return AuthorizationMode.RBAC;
});
actionsClient = new ActionsClient({
inMemoryConnectors: [
{
id: 'system-connector-.cases',
actionTypeId: '.cases',
name: 'System action: .cases',
config: {},
secrets: {},
isDeprecated: false,
isMissingSecrets: false,
isPreconfigured: false,
isSystemAction: true,
},
],
logger,
actionTypeRegistry,
unsecuredSavedObjectsClient,
scopedClusterClient,
kibanaIndices,
actionExecutor,
executionEnqueuer,
ephemeralExecutionEnqueuer,
bulkExecutionEnqueuer,
request,
authorization: authorization as unknown as ActionsAuthorization,
auditLogger,
usageCounter: mockUsageCounter,
connectorTokenClient,
getEventLogClient,
});
actionTypeRegistry.register({
id: '.cases',
name: 'Cases',
minimumLicenseRequired: 'platinum',
supportedFeatureIds: ['alerting'],
getKibanaPrivileges: () => ['test/create'],
validate: {
config: { schema: schema.object({}) },
secrets: { schema: schema.object({}) },
params: { schema: schema.object({}) },
},
isSystemActionType: true,
executor,
});
await actionsClient.execute({
actionId: 'system-connector-.cases',
params: {},
});
expect(authorization.ensureAuthorized).toHaveBeenCalledWith({
operation: 'execute',
additionalPrivileges: ['test/create'],
});
});
test('does not authorize kibana privileges for non system actions', async () => {
(getAuthorizationModeBySource as jest.Mock).mockImplementationOnce(() => {
return AuthorizationMode.RBAC;
});
actionsClient = new ActionsClient({
inMemoryConnectors: [
{
id: 'testPreconfigured',
actionTypeId: 'my-action-type',
secrets: {
test: 'test1',
},
isPreconfigured: true,
isDeprecated: false,
isSystemAction: false,
name: 'test',
config: {
foo: 'bar',
},
},
],
logger,
actionTypeRegistry,
unsecuredSavedObjectsClient,
scopedClusterClient,
kibanaIndices,
actionExecutor,
executionEnqueuer,
ephemeralExecutionEnqueuer,
bulkExecutionEnqueuer,
request,
authorization: authorization as unknown as ActionsAuthorization,
auditLogger,
usageCounter: mockUsageCounter,
connectorTokenClient,
getEventLogClient,
});
actionTypeRegistry.register({
id: '.cases',
name: 'Cases',
minimumLicenseRequired: 'platinum',
supportedFeatureIds: ['alerting'],
getKibanaPrivileges: () => ['test/create'],
validate: {
config: { schema: schema.object({}) },
secrets: { schema: schema.object({}) },
params: { schema: schema.object({}) },
},
isSystemActionType: true,
executor,
});
await actionsClient.execute({
actionId: 'testPreconfigured',
params: {},
});
expect(authorization.ensureAuthorized).toHaveBeenCalledWith({
operation: 'execute',
additionalPrivileges: [],
});
});
test('pass the params to the actionTypeRegistry when authorizing system actions', async () => {
(getAuthorizationModeBySource as jest.Mock).mockImplementationOnce(() => {
return AuthorizationMode.RBAC;
});
const getKibanaPrivileges = jest.fn().mockReturnValue(['test/create']);
actionsClient = new ActionsClient({
inMemoryConnectors: [
{
id: 'system-connector-.cases',
actionTypeId: '.cases',
name: 'System action: .cases',
config: {},
secrets: {},
isDeprecated: false,
isMissingSecrets: false,
isPreconfigured: false,
isSystemAction: true,
},
],
logger,
actionTypeRegistry,
unsecuredSavedObjectsClient,
scopedClusterClient,
kibanaIndices,
actionExecutor,
executionEnqueuer,
ephemeralExecutionEnqueuer,
bulkExecutionEnqueuer,
request,
authorization: authorization as unknown as ActionsAuthorization,
auditLogger,
usageCounter: mockUsageCounter,
connectorTokenClient,
getEventLogClient,
});
actionTypeRegistry.register({
id: '.cases',
name: 'Cases',
minimumLicenseRequired: 'platinum',
supportedFeatureIds: ['alerting'],
getKibanaPrivileges,
validate: {
config: { schema: schema.object({}) },
secrets: { schema: schema.object({}) },
params: { schema: schema.object({}) },
},
isSystemActionType: true,
executor,
});
await actionsClient.execute({
actionId: 'system-connector-.cases',
params: { foo: 'bar' },
});
expect(authorization.ensureAuthorized).toHaveBeenCalledWith({
operation: 'execute',
additionalPrivileges: ['test/create'],
});
expect(getKibanaPrivileges).toHaveBeenCalledWith({ params: { foo: 'bar' } });
});
});
@ -2888,7 +3094,9 @@ describe('enqueueExecution()', () => {
apiKey: null,
source: asHttpRequestExecutionSource(request),
});
expect(authorization.ensureAuthorized).toHaveBeenCalledWith('execute');
expect(authorization.ensureAuthorized).toHaveBeenCalledWith({
operation: 'execute',
});
});
test('throws when user is not authorised to create the type of action', async () => {
@ -2910,7 +3118,9 @@ describe('enqueueExecution()', () => {
})
).rejects.toMatchInlineSnapshot(`[Error: Unauthorized to execute all actions]`);
expect(authorization.ensureAuthorized).toHaveBeenCalledWith('execute');
expect(authorization.ensureAuthorized).toHaveBeenCalledWith({
operation: 'execute',
});
});
test('tracks legacy RBAC', async () => {
@ -2973,7 +3183,9 @@ describe('bulkEnqueueExecution()', () => {
source: asHttpRequestExecutionSource(request),
},
]);
expect(authorization.ensureAuthorized).toHaveBeenCalledWith('execute');
expect(authorization.ensureAuthorized).toHaveBeenCalledWith({
operation: 'execute',
});
});
test('throws when user is not authorised to create the type of action', async () => {
@ -3005,7 +3217,9 @@ describe('bulkEnqueueExecution()', () => {
])
).rejects.toMatchInlineSnapshot(`[Error: Unauthorized to execute all actions]`);
expect(authorization.ensureAuthorized).toHaveBeenCalledWith('execute');
expect(authorization.ensureAuthorized).toHaveBeenCalledWith({
operation: 'execute',
});
});
test('tracks legacy RBAC', async () => {
@ -3341,7 +3555,7 @@ describe('getGlobalExecutionLogWithAuth()', () => {
return AuthorizationMode.RBAC;
});
await actionsClient.getGlobalExecutionLogWithAuth(opts);
expect(authorization.ensureAuthorized).toHaveBeenCalledWith('get');
expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ operation: 'get' });
});
test('throws when user is not authorised to access logs', async () => {
@ -3354,7 +3568,7 @@ describe('getGlobalExecutionLogWithAuth()', () => {
`[Error: Unauthorized to access logs]`
);
expect(authorization.ensureAuthorized).toHaveBeenCalledWith('get');
expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ operation: 'get' });
});
});
@ -3396,7 +3610,7 @@ describe('getGlobalExecutionKpiWithAuth()', () => {
return AuthorizationMode.RBAC;
});
await actionsClient.getGlobalExecutionKpiWithAuth(opts);
expect(authorization.ensureAuthorized).toHaveBeenCalledWith('get');
expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ operation: 'get' });
});
test('throws when user is not authorised to access kpi', async () => {
@ -3409,7 +3623,7 @@ describe('getGlobalExecutionKpiWithAuth()', () => {
`[Error: Unauthorized to access kpi]`
);
expect(authorization.ensureAuthorized).toHaveBeenCalledWith('get');
expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ operation: 'get' });
});
});

View file

@ -197,7 +197,7 @@ export class ActionsClient {
const id = options?.id || SavedObjectsUtils.generateId();
try {
await this.authorization.ensureAuthorized('create', actionTypeId);
await this.authorization.ensureAuthorized({ operation: 'create', actionTypeId });
} catch (error) {
this.auditLogger?.log(
connectorAuditEvent({
@ -286,7 +286,7 @@ export class ActionsClient {
*/
public async update({ id, action }: UpdateOptions): Promise<ActionResult> {
try {
await this.authorization.ensureAuthorized('update');
await this.authorization.ensureAuthorized({ operation: 'update' });
const foundInMemoryConnector = this.inMemoryConnectors.find(
(connector) => connector.id === id
@ -396,7 +396,7 @@ export class ActionsClient {
*/
public async get({ id }: { id: string }): Promise<ActionResult> {
try {
await this.authorization.ensureAuthorized('get');
await this.authorization.ensureAuthorized({ operation: 'get' });
} catch (error) {
this.auditLogger?.log(
connectorAuditEvent({
@ -454,7 +454,7 @@ export class ActionsClient {
*/
public async getAll(): Promise<FindActionResult[]> {
try {
await this.authorization.ensureAuthorized('get');
await this.authorization.ensureAuthorized({ operation: 'get' });
} catch (error) {
this.auditLogger?.log(
connectorAuditEvent({
@ -502,7 +502,7 @@ export class ActionsClient {
*/
public async getBulk(ids: string[]): Promise<ActionResult[]> {
try {
await this.authorization.ensureAuthorized('get');
await this.authorization.ensureAuthorized({ operation: 'get' });
} catch (error) {
ids.forEach((id) =>
this.auditLogger?.log(
@ -569,7 +569,7 @@ export class ActionsClient {
configurationUtilities: ActionsConfigurationUtilities
) {
// Verify that user has edit access
await this.authorization.ensureAuthorized('update');
await this.authorization.ensureAuthorized({ operation: 'update' });
// Verify that token url is allowed by allowed hosts config
try {
@ -660,7 +660,7 @@ export class ActionsClient {
*/
public async delete({ id }: { id: string }) {
try {
await this.authorization.ensureAuthorized('delete');
await this.authorization.ensureAuthorized({ operation: 'delete' });
const foundInMemoryConnector = this.inMemoryConnectors.find(
(connector) => connector.id === id
@ -718,6 +718,21 @@ export class ActionsClient {
return await this.unsecuredSavedObjectsClient.delete('action', id);
}
private getSystemActionKibanaPrivileges(connectorId: string, params?: ExecuteOptions['params']) {
const inMemoryConnector = this.inMemoryConnectors.find(
(connector) => connector.id === connectorId
);
const additionalPrivileges = inMemoryConnector?.isSystemAction
? this.actionTypeRegistry.getSystemActionKibanaPrivileges(
inMemoryConnector.actionTypeId,
params
)
: [];
return additionalPrivileges;
}
public async execute({
actionId,
params,
@ -730,7 +745,8 @@ export class ActionsClient {
(await getAuthorizationModeBySource(this.unsecuredSavedObjectsClient, source)) ===
AuthorizationMode.RBAC
) {
await this.authorization.ensureAuthorized('execute');
const additionalPrivileges = this.getSystemActionKibanaPrivileges(actionId, params);
await this.authorization.ensureAuthorized({ operation: 'execute', additionalPrivileges });
} else {
trackLegacyRBACExemption('execute', this.usageCounter);
}
@ -751,7 +767,13 @@ export class ActionsClient {
(await getAuthorizationModeBySource(this.unsecuredSavedObjectsClient, source)) ===
AuthorizationMode.RBAC
) {
await this.authorization.ensureAuthorized('execute');
/**
* For scheduled executions the additional authorization check
* for system actions (kibana privileges) will be performed
* inside the ActionExecutor at execution time
*/
await this.authorization.ensureAuthorized({ operation: 'execute' });
} else {
trackLegacyRBACExemption('enqueueExecution', this.usageCounter);
}
@ -765,12 +787,18 @@ export class ActionsClient {
sources.push(option.source);
}
});
const authCounts = await getBulkAuthorizationModeBySource(
this.unsecuredSavedObjectsClient,
sources
);
if (authCounts[AuthorizationMode.RBAC] > 0) {
await this.authorization.ensureAuthorized('execute');
/**
* For scheduled executions the additional authorization check
* for system actions (kibana privileges) will be performed
* inside the ActionExecutor at execution time
*/
await this.authorization.ensureAuthorized({ operation: 'execute' });
}
if (authCounts[AuthorizationMode.Legacy] > 0) {
trackLegacyRBACExemption(
@ -788,7 +816,7 @@ export class ActionsClient {
(await getAuthorizationModeBySource(this.unsecuredSavedObjectsClient, source)) ===
AuthorizationMode.RBAC
) {
await this.authorization.ensureAuthorized('execute');
await this.authorization.ensureAuthorized({ operation: 'execute' });
} else {
trackLegacyRBACExemption('ephemeralEnqueuedExecution', this.usageCounter);
}
@ -831,7 +859,7 @@ export class ActionsClient {
const authorizationTuple = {} as KueryNode;
try {
await this.authorization.ensureAuthorized('get');
await this.authorization.ensureAuthorized({ operation: 'get' });
} catch (error) {
this.auditLogger?.log(
connectorAuditEvent({
@ -891,7 +919,7 @@ export class ActionsClient {
const authorizationTuple = {} as KueryNode;
try {
await this.authorization.ensureAuthorized('get');
await this.authorization.ensureAuthorized({ operation: 'get' });
} catch (error) {
this.auditLogger?.log(
connectorAuditEvent({

View file

@ -18,6 +18,7 @@ import { AuthorizationMode } from './get_authorization_mode_by_source';
const request = {} as KibanaRequest;
const mockAuthorizationAction = (type: string, operation: string) => `${type}/${operation}`;
function mockSecurity() {
const security = securityMock.createSetup();
const authorization = security.authz;
@ -42,7 +43,7 @@ describe('ensureAuthorized', () => {
request,
});
await actionsAuthorization.ensureAuthorized('create', 'myType');
await actionsAuthorization.ensureAuthorized({ operation: 'create', actionTypeId: 'myType' });
});
test('is a no-op when the security license is disabled', async () => {
@ -53,7 +54,7 @@ describe('ensureAuthorized', () => {
authorization,
});
await actionsAuthorization.ensureAuthorized('create', 'myType');
await actionsAuthorization.ensureAuthorized({ operation: 'create', actionTypeId: 'myType' });
});
test('ensures the user has privileges to use the operation on the Actions Saved Object type', async () => {
@ -78,11 +79,11 @@ describe('ensureAuthorized', () => {
],
});
await actionsAuthorization.ensureAuthorized('create', 'myType');
await actionsAuthorization.ensureAuthorized({ operation: 'create', actionTypeId: 'myType' });
expect(authorization.actions.savedObject.get).toHaveBeenCalledWith('action', 'create');
expect(checkPrivileges).toHaveBeenCalledWith({
kibana: mockAuthorizationAction('action', 'create'),
kibana: [mockAuthorizationAction('action', 'create')],
});
});
@ -108,7 +109,7 @@ describe('ensureAuthorized', () => {
],
});
await actionsAuthorization.ensureAuthorized('execute', 'myType');
await actionsAuthorization.ensureAuthorized({ operation: 'execute', actionTypeId: 'myType' });
expect(authorization.actions.savedObject.get).toHaveBeenCalledWith(
ACTION_SAVED_OBJECT_TYPE,
@ -153,7 +154,7 @@ describe('ensureAuthorized', () => {
});
await expect(
actionsAuthorization.ensureAuthorized('create', 'myType')
actionsAuthorization.ensureAuthorized({ operation: 'create', actionTypeId: 'myType' })
).rejects.toThrowErrorMatchingInlineSnapshot(`"Unauthorized to create a \\"myType\\" action"`);
});
@ -174,9 +175,57 @@ describe('ensureAuthorized', () => {
username: 'some-user',
} as unknown as AuthenticatedUser);
await actionsAuthorization.ensureAuthorized('execute', 'myType');
await actionsAuthorization.ensureAuthorized({ operation: 'execute', actionTypeId: 'myType' });
expect(authorization.actions.savedObject.get).not.toHaveBeenCalled();
expect(checkPrivileges).not.toHaveBeenCalled();
});
test('checks additional privileges correctly', async () => {
const { authorization } = mockSecurity();
const checkPrivileges: jest.MockedFunction<
ReturnType<typeof authorization.checkPrivilegesDynamicallyWithRequest>
> = jest.fn();
authorization.checkPrivilegesDynamicallyWithRequest.mockReturnValue(checkPrivileges);
const actionsAuthorization = new ActionsAuthorization({
request,
authorization,
});
checkPrivileges.mockResolvedValueOnce({
username: 'some-user',
hasAllRequested: true,
privileges: [
{
privilege: mockAuthorizationAction('myType', 'execute'),
authorized: true,
},
],
});
await actionsAuthorization.ensureAuthorized({
operation: 'execute',
actionTypeId: 'myType',
additionalPrivileges: ['test/create'],
});
expect(authorization.actions.savedObject.get).toHaveBeenCalledWith(
ACTION_SAVED_OBJECT_TYPE,
'get'
);
expect(authorization.actions.savedObject.get).toHaveBeenCalledWith(
ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE,
'create'
);
expect(checkPrivileges).toHaveBeenCalledWith({
kibana: [
mockAuthorizationAction(ACTION_SAVED_OBJECT_TYPE, 'get'),
mockAuthorizationAction(ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE, 'create'),
'test/create',
],
});
});
});

View file

@ -28,15 +28,14 @@ export interface ConstructorOptions {
authorizationMode?: AuthorizationMode;
}
const operationAlias: Record<
string,
(authorization: SecurityPluginSetup['authz']) => string | string[]
> = {
const operationAlias: Record<string, (authorization: SecurityPluginSetup['authz']) => string[]> = {
execute: (authorization) => [
authorization.actions.savedObject.get(ACTION_SAVED_OBJECT_TYPE, 'get'),
authorization.actions.savedObject.get(ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE, 'create'),
],
list: (authorization) => authorization.actions.savedObject.get(ACTION_SAVED_OBJECT_TYPE, 'find'),
list: (authorization) => [
authorization.actions.savedObject.get(ACTION_SAVED_OBJECT_TYPE, 'find'),
],
};
const LEGACY_RBAC_EXEMPT_OPERATIONS = new Set(['get', 'execute']);
@ -56,15 +55,26 @@ export class ActionsAuthorization {
this.authorizationMode = authorizationMode;
}
public async ensureAuthorized(operation: string, actionTypeId?: string) {
public async ensureAuthorized({
operation,
actionTypeId,
additionalPrivileges = [],
}: {
operation: string;
actionTypeId?: string;
additionalPrivileges?: string[];
}) {
const { authorization } = this;
if (authorization?.mode?.useRbacForRequest(this.request)) {
if (!this.isOperationExemptDueToLegacyRbac(operation)) {
const checkPrivileges = authorization.checkPrivilegesDynamicallyWithRequest(this.request);
const privileges = operationAlias[operation]
? operationAlias[operation](authorization)
: [authorization.actions.savedObject.get(ACTION_SAVED_OBJECT_TYPE, operation)];
const { hasAllRequested } = await checkPrivileges({
kibana: operationAlias[operation]
? operationAlias[operation](authorization)
: authorization.actions.savedObject.get(ACTION_SAVED_OBJECT_TYPE, operation),
kibana: [...privileges, ...additionalPrivileges],
});
if (!hasAllRequested) {
throw Boom.forbidden(

View file

@ -14,7 +14,7 @@ import { httpServerMock, loggingSystemMock } from '@kbn/core/server/mocks';
import { eventLoggerMock } from '@kbn/event-log-plugin/server/mocks';
import { spacesServiceMock } from '@kbn/spaces-plugin/server/spaces_service/spaces_service.mock';
import { ActionType } from '../types';
import { actionsMock } from '../mocks';
import { actionsAuthorizationMock, actionsMock } from '../mocks';
import {
asHttpRequestExecutionSource,
asSavedObjectExecutionSource,
@ -43,6 +43,9 @@ const loggerMock: ReturnType<typeof loggingSystemMock.createLogger> =
loggingSystemMock.createLogger();
const securityMockStart = securityMock.createStart();
const authorizationMock = actionsAuthorizationMock.create();
const getActionsAuthorizationWithRequest = jest.fn();
actionExecutor.initialize({
logger: loggerMock,
spaces: spacesMock,
@ -51,6 +54,7 @@ actionExecutor.initialize({
actionTypeRegistry,
encryptedSavedObjectsClient,
eventLogger,
getActionsAuthorizationWithRequest,
inMemoryConnectors: [
{
id: 'preconfigured',
@ -95,6 +99,8 @@ beforeEach(() => {
roles: ['superuser'],
username: 'coolguy',
}));
getActionsAuthorizationWithRequest.mockReturnValue(authorizationMock);
});
test('successfully executes', async () => {
@ -681,6 +687,7 @@ test('successfully executes with system connector', async () => {
name: 'Cases',
minimumLicenseRequired: 'platinum',
supportedFeatureIds: ['alerting'],
isSystemActionType: true,
validate: {
config: { schema: schema.any() },
secrets: { schema: schema.any() },
@ -802,6 +809,92 @@ test('successfully executes with system connector', async () => {
`);
});
test('successfully authorize system actions', async () => {
const actionType: jest.Mocked<ActionType> = {
id: '.cases',
name: 'Cases',
minimumLicenseRequired: 'platinum',
supportedFeatureIds: ['alerting'],
getKibanaPrivileges: () => ['test/create'],
isSystemActionType: true,
validate: {
config: { schema: schema.any() },
secrets: { schema: schema.any() },
params: { schema: schema.any() },
},
executor: jest.fn(),
};
actionTypeRegistry.get.mockReturnValueOnce(actionType);
actionTypeRegistry.isSystemActionType.mockReturnValueOnce(true);
actionTypeRegistry.getSystemActionKibanaPrivileges.mockReturnValueOnce(['test/create']);
await actionExecutor.execute({ ...executeParams, actionId: 'system-connector-.cases' });
expect(authorizationMock.ensureAuthorized).toBeCalledWith({
operation: 'execute',
additionalPrivileges: ['test/create'],
});
});
test('pass the params to the actionTypeRegistry when authorizing system actions', async () => {
const actionType: jest.Mocked<ActionType> = {
id: '.cases',
name: 'Cases',
minimumLicenseRequired: 'platinum',
supportedFeatureIds: ['alerting'],
getKibanaPrivileges: () => ['test/create'],
isSystemActionType: true,
validate: {
config: { schema: schema.any() },
secrets: { schema: schema.any() },
params: { schema: schema.any() },
},
executor: jest.fn(),
};
actionTypeRegistry.get.mockReturnValueOnce(actionType);
actionTypeRegistry.isSystemActionType.mockReturnValueOnce(true);
actionTypeRegistry.getSystemActionKibanaPrivileges.mockReturnValueOnce(['test/create']);
await actionExecutor.execute({
...executeParams,
params: { foo: 'bar' },
actionId: 'system-connector-.cases',
});
expect(actionTypeRegistry.getSystemActionKibanaPrivileges).toHaveBeenCalledWith('.cases', {
foo: 'bar',
});
expect(authorizationMock.ensureAuthorized).toBeCalledWith({
operation: 'execute',
additionalPrivileges: ['test/create'],
});
});
test('does not authorize non system actions', async () => {
const actionType: jest.Mocked<ActionType> = {
id: 'test',
name: 'Test',
minimumLicenseRequired: 'basic',
supportedFeatureIds: ['alerting'],
validate: {
config: { schema: schema.object({ bar: schema.string() }) },
secrets: { schema: schema.object({ apiKey: schema.string() }) },
params: { schema: schema.object({ foo: schema.boolean() }) },
},
executor: jest.fn(),
};
actionTypeRegistry.get.mockReturnValueOnce(actionType);
actionTypeRegistry.isSystemActionType.mockReturnValueOnce(false);
await actionExecutor.execute({ ...executeParams, actionId: 'preconfigured' });
expect(authorizationMock.ensureAuthorized).not.toBeCalled();
});
test('successfully executes as a task', async () => {
const actionType: jest.Mocked<ActionType> = {
id: 'test',
@ -1102,6 +1195,7 @@ test('should not throws an error if actionType is system action', async () => {
name: 'Cases',
minimumLicenseRequired: 'platinum',
supportedFeatureIds: ['alerting'],
isSystemActionType: true,
validate: {
config: { schema: schema.any() },
secrets: { schema: schema.any() },
@ -1151,6 +1245,7 @@ test('throws an error when passing isESOCanEncrypt with value of false', async (
encryptedSavedObjectsClient,
eventLogger: eventLoggerMock.create(),
inMemoryConnectors: [],
getActionsAuthorizationWithRequest,
});
await expect(
customActionExecutor.execute(executeParams)
@ -1168,6 +1263,7 @@ test('should not throw error if action is preconfigured and isESOCanEncrypt is f
actionTypeRegistry,
encryptedSavedObjectsClient,
eventLogger: eventLoggerMock.create(),
getActionsAuthorizationWithRequest,
inMemoryConnectors: [
{
id: 'preconfigured',
@ -1318,6 +1414,7 @@ test('should not throw error if action is system action and isESOCanEncrypt is f
actionTypeRegistry,
encryptedSavedObjectsClient,
eventLogger: eventLoggerMock.create(),
getActionsAuthorizationWithRequest,
inMemoryConnectors: [
{
actionTypeId: '.cases',
@ -1337,6 +1434,7 @@ test('should not throw error if action is system action and isESOCanEncrypt is f
name: 'Cases',
minimumLicenseRequired: 'platinum',
supportedFeatureIds: ['alerting'],
isSystemActionType: true,
validate: {
config: { schema: schema.any() },
secrets: { schema: schema.any() },

View file

@ -36,6 +36,7 @@ import { ActionExecutionSource } from './action_execution_source';
import { RelatedSavedObjects } from './related_saved_objects';
import { createActionEventLogRecordObject } from './create_action_event_log_record_object';
import { ActionExecutionError, ActionExecutionErrorReason } from './errors/action_execution_error';
import type { ActionsAuthorization } from '../authorization/actions_authorization';
// 1,000,000 nanoseconds in 1 millisecond
const Millis2Nanos = 1000 * 1000;
@ -49,6 +50,7 @@ export interface ActionExecutorContext {
actionTypeRegistry: ActionTypeRegistryContract;
eventLogger: IEventLogger;
inMemoryConnectors: InMemoryConnector[];
getActionsAuthorizationWithRequest: (request: KibanaRequest) => ActionsAuthorization;
}
export interface TaskInfo {
@ -108,6 +110,7 @@ export class ActionExecutor {
if (!this.isInitialized) {
throw new Error('ActionExecutor not initialized');
}
return withSpan(
{
name: `execute_action`,
@ -117,12 +120,19 @@ export class ActionExecutor {
},
},
async (span) => {
const { spaces, getServices, actionTypeRegistry, eventLogger, security } =
this.actionExecutorContext!;
const {
spaces,
getServices,
actionTypeRegistry,
eventLogger,
security,
getActionsAuthorizationWithRequest,
} = this.actionExecutorContext!;
const services = getServices(request);
const spaceId = spaces && spaces.getSpaceId(request);
const namespace = spaceId && spaceId !== 'default' ? { namespace: spaceId } : {};
const authorization = getActionsAuthorizationWithRequest(request);
const actionInfo =
actionInfoFromTaskRunner ||
@ -223,6 +233,19 @@ export class ActionExecutor {
let rawResult: ActionTypeExecutorRawResult<unknown>;
try {
/**
* Ensures correct permissions for execution and
* performs authorization checks for system actions.
* It will thrown an error in case of failure.
*/
await ensureAuthorizedToExecute({
params,
actionId,
actionTypeId,
actionTypeRegistry,
authorization,
});
rawResult = await actionType.executor({
actionId,
services,
@ -236,7 +259,10 @@ export class ActionExecutor {
source,
});
} catch (err) {
if (err.reason === ActionExecutionErrorReason.Validation) {
if (
err.reason === ActionExecutionErrorReason.Validation ||
err.reason === ActionExecutionErrorReason.Authorization
) {
rawResult = err.result;
} else {
rawResult = {
@ -507,3 +533,37 @@ function validateAction(
});
}
}
interface EnsureAuthorizedToExecuteOpts {
actionId: string;
actionTypeId: string;
params: Record<string, unknown>;
actionTypeRegistry: ActionTypeRegistryContract;
authorization: ActionsAuthorization;
}
const ensureAuthorizedToExecute = async ({
actionId,
actionTypeId,
params,
actionTypeRegistry,
authorization,
}: EnsureAuthorizedToExecuteOpts) => {
try {
if (actionTypeRegistry.isSystemActionType(actionTypeId)) {
const additionalPrivileges = actionTypeRegistry.getSystemActionKibanaPrivileges(
actionTypeId,
params
);
await authorization.ensureAuthorized({ operation: 'execute', additionalPrivileges });
}
} catch (error) {
throw new ActionExecutionError(error.message, ActionExecutionErrorReason.Authorization, {
actionId,
status: 'error',
message: error.message,
retry: false,
});
}
};

View file

@ -9,6 +9,7 @@ import { ActionTypeExecutorResult } from '../../types';
export enum ActionExecutionErrorReason {
Validation = 'validation',
Authorization = 'authorization',
}
export class ActionExecutionError extends Error {

View file

@ -20,7 +20,7 @@ import {
} from '@kbn/core/server/mocks';
import { eventLoggerMock } from '@kbn/event-log-plugin/server/mocks';
import { ActionTypeDisabledError } from './errors';
import { actionsClientMock } from '../mocks';
import { actionsAuthorizationMock } from '../mocks';
import { inMemoryMetricsMock } from '../monitoring/in_memory_metrics.mock';
import { IN_MEMORY_METRICS } from '../monitoring';
import { pick } from 'lodash';
@ -106,15 +106,17 @@ const services = {
log: jest.fn(),
savedObjectsClient: savedObjectsClientMock.create(),
};
const actionExecutorInitializerParams = {
logger: loggingSystemMock.create().get(),
getServices: jest.fn().mockReturnValue(services),
actionTypeRegistry,
getActionsClientWithRequest: jest.fn(async () => actionsClientMock.create()),
getActionsAuthorizationWithRequest: jest.fn().mockReturnValue(actionsAuthorizationMock.create()),
encryptedSavedObjectsClient: mockedEncryptedSavedObjectsClient,
eventLogger,
inMemoryConnectors: [],
};
const taskRunnerFactoryInitializerParams = {
spaceIdToNamespace,
actionTypeRegistry,

View file

@ -513,6 +513,9 @@ export class ActionsPlugin implements Plugin<PluginSetupContract, PluginStartCon
encryptedSavedObjectsClient,
actionTypeRegistry: actionTypeRegistry!,
inMemoryConnectors: this.inMemoryConnectors,
getActionsAuthorizationWithRequest(request: KibanaRequest) {
return instantiateAuthorization(request);
},
});
taskRunnerFactory!.initialize({

View file

@ -151,6 +151,18 @@ export interface ActionType<
connector?: (config: Config, secrets: Secrets) => string | null;
};
isSystemActionType?: boolean;
/**
* Additional Kibana privileges to be checked by the actions framework.
* Use it if you want to perform extra authorization checks based on a Kibana feature.
* For example, you can define the privileges a users needs to have to execute
* a Case or OsQuery system action.
*
* The list of the privileges follows the Kibana privileges format usually generated with `security.authz.actions.*.get(...)`.
*
* It only works with system actions and only when executing an action.
* For all other scenarios they will be ignored
*/
getKibanaPrivileges?: (args?: { params?: Params }) => string[];
renderParameterTemplates?: RenderParameterTemplates<Params>;
executor: ExecutorType<Config, Secrets, Params, ExecutorResultData>;
}

View file

@ -576,7 +576,7 @@ async function ensureAuthorizationForBulkUpdate(
const { field } = operation;
if (field === 'snoozeSchedule' || field === 'apiKey') {
try {
await context.actionsAuthorization.ensureAuthorized('execute');
await context.actionsAuthorization.ensureAuthorized({ operation: 'execute' });
break;
} catch (error) {
throw Error(`Rule not authorized for bulk ${field} update - ${error.message}`);

View file

@ -134,7 +134,7 @@ const bulkEnableRulesWithOCC = async (
try {
if (rule.attributes.actions.length) {
try {
await context.actionsAuthorization.ensureAuthorized('execute');
await context.actionsAuthorization.ensureAuthorized({ operation: 'execute' });
} catch (error) {
throw Error(`Rule not authorized for bulk enable - ${error.message}`);
}

View file

@ -55,7 +55,7 @@ async function enableWithOCC(context: RulesClientContext, { id }: { id: string }
});
if (attributes.actions.length) {
await context.actionsAuthorization.ensureAuthorized('execute');
await context.actionsAuthorization.ensureAuthorized({ operation: 'execute' });
}
} catch (error) {
context.auditLogger?.log(

View file

@ -37,7 +37,7 @@ async function muteAllWithOCC(context: RulesClientContext, { id }: { id: string
});
if (attributes.actions.length) {
await context.actionsAuthorization.ensureAuthorized('execute');
await context.actionsAuthorization.ensureAuthorized({ operation: 'execute' });
}
} catch (error) {
context.auditLogger?.log(

View file

@ -42,7 +42,7 @@ async function muteInstanceWithOCC(
});
if (attributes.actions.length) {
await context.actionsAuthorization.ensureAuthorized('execute');
await context.actionsAuthorization.ensureAuthorized({ operation: 'execute' });
}
} catch (error) {
context.auditLogger?.log(

View file

@ -23,7 +23,7 @@ export async function runSoon(context: RulesClientContext, { id }: { id: string
});
if (attributes.actions.length) {
await context.actionsAuthorization.ensureAuthorized('execute');
await context.actionsAuthorization.ensureAuthorized({ operation: 'execute' });
}
} catch (error) {
context.auditLogger?.log(

View file

@ -62,7 +62,7 @@ async function snoozeWithOCC(
});
if (attributes.actions.length) {
await context.actionsAuthorization.ensureAuthorized('execute');
await context.actionsAuthorization.ensureAuthorized({ operation: 'execute' });
}
} catch (error) {
context.auditLogger?.log(

View file

@ -40,7 +40,7 @@ async function unmuteAllWithOCC(context: RulesClientContext, { id }: { id: strin
});
if (attributes.actions.length) {
await context.actionsAuthorization.ensureAuthorized('execute');
await context.actionsAuthorization.ensureAuthorized({ operation: 'execute' });
}
} catch (error) {
context.auditLogger?.log(

View file

@ -47,7 +47,7 @@ async function unmuteInstanceWithOCC(
entity: AlertingAuthorizationEntity.Rule,
});
if (attributes.actions.length) {
await context.actionsAuthorization.ensureAuthorized('execute');
await context.actionsAuthorization.ensureAuthorized({ operation: 'execute' });
}
} catch (error) {
context.auditLogger?.log(

View file

@ -45,7 +45,7 @@ async function unsnoozeWithOCC(context: RulesClientContext, { id, scheduleIds }:
});
if (attributes.actions.length) {
await context.actionsAuthorization.ensureAuthorized('execute');
await context.actionsAuthorization.ensureAuthorized({ operation: 'execute' });
}
} catch (error) {
context.auditLogger?.log(

View file

@ -58,7 +58,7 @@ async function updateApiKeyWithOCC(context: RulesClientContext, { id }: { id: st
entity: AlertingAuthorizationEntity.Rule,
});
if (attributes.actions.length) {
await context.actionsAuthorization.ensureAuthorized('execute');
await context.actionsAuthorization.ensureAuthorized({ operation: 'execute' });
}
} catch (error) {
context.auditLogger?.log(

View file

@ -148,7 +148,7 @@ describe('enable()', () => {
operation: 'enable',
ruleTypeId: 'myType',
});
expect(actionsAuthorization.ensureAuthorized).toHaveBeenCalledWith('execute');
expect(actionsAuthorization.ensureAuthorized).toHaveBeenCalledWith({ operation: 'execute' });
});
test('throws when user is not authorised to enable this type of alert', async () => {

View file

@ -135,7 +135,7 @@ describe('muteAll()', () => {
operation: 'muteAll',
ruleTypeId: 'myType',
});
expect(actionsAuthorization.ensureAuthorized).toHaveBeenCalledWith('execute');
expect(actionsAuthorization.ensureAuthorized).toHaveBeenCalledWith({ operation: 'execute' });
});
test('throws when user is not authorised to muteAll this type of alert', async () => {

View file

@ -160,7 +160,7 @@ describe('muteInstance()', () => {
const rulesClient = new RulesClient(rulesClientParams);
await rulesClient.muteInstance({ alertId: '1', alertInstanceId: '2' });
expect(actionsAuthorization.ensureAuthorized).toHaveBeenCalledWith('execute');
expect(actionsAuthorization.ensureAuthorized).toHaveBeenCalledWith({ operation: 'execute' });
expect(authorization.ensureAuthorized).toHaveBeenCalledWith({
entity: 'rule',
consumer: 'myApp',

View file

@ -115,7 +115,7 @@ describe('runSoon()', () => {
operation: 'runSoon',
ruleTypeId: 'myType',
});
expect(actionsAuthorization.ensureAuthorized).toHaveBeenCalledWith('execute');
expect(actionsAuthorization.ensureAuthorized).toHaveBeenCalledWith({ operation: 'execute' });
});
test('throws when user is not authorised to run this type of rule ad hoc', async () => {

View file

@ -135,7 +135,7 @@ describe('unmuteAll()', () => {
operation: 'unmuteAll',
ruleTypeId: 'myType',
});
expect(actionsAuthorization.ensureAuthorized).toHaveBeenCalledWith('execute');
expect(actionsAuthorization.ensureAuthorized).toHaveBeenCalledWith({ operation: 'execute' });
});
test('throws when user is not authorised to unmuteAll this type of alert', async () => {

View file

@ -158,7 +158,7 @@ describe('unmuteInstance()', () => {
const rulesClient = new RulesClient(rulesClientParams);
await rulesClient.unmuteInstance({ alertId: '1', alertInstanceId: '2' });
expect(actionsAuthorization.ensureAuthorized).toHaveBeenCalledWith('execute');
expect(actionsAuthorization.ensureAuthorized).toHaveBeenCalledWith({ operation: 'execute' });
expect(authorization.ensureAuthorized).toHaveBeenCalledWith({
entity: 'rule',
consumer: 'myApp',

View file

@ -355,7 +355,7 @@ describe('updateApiKey()', () => {
test('ensures user is authorised to updateApiKey this type of alert under the consumer', async () => {
await rulesClient.updateApiKey({ id: '1' });
expect(actionsAuthorization.ensureAuthorized).toHaveBeenCalledWith('execute');
expect(actionsAuthorization.ensureAuthorized).toHaveBeenCalledWith({ operation: 'execute' });
expect(authorization.ensureAuthorized).toHaveBeenCalledWith({
entity: 'rule',
consumer: 'myApp',

View file

@ -65,6 +65,7 @@ const enabledActionTypes = [
'test.excluded',
'test.capped',
'test.system-action',
'test.system-action-kibana-privileges',
];
export function createTestConfig(name: string, options: CreateTestConfigOptions) {

View file

@ -74,7 +74,12 @@ export function defineActionTypes(
actions.registerType(getNoAttemptsRateLimitedActionType());
actions.registerType(getAuthorizationActionType(core));
actions.registerType(getExcludedActionType());
/**
* System actions
*/
actions.registerType(getSystemActionType());
actions.registerType(getSystemActionTypeWithKibanaPrivileges());
/** Sub action framework */
@ -426,3 +431,56 @@ function getSystemActionType() {
return result;
}
function getSystemActionTypeWithKibanaPrivileges() {
const result: ActionType<{}, {}, { index?: string; reference?: string }> = {
id: 'test.system-action-kibana-privileges',
name: 'Test system action with kibana privileges',
minimumLicenseRequired: 'platinum',
supportedFeatureIds: ['alerting'],
/**
* Requires all access to the case feature
* in Stack management
*/
getKibanaPrivileges: () => ['cases:cases/createCase'],
validate: {
params: {
schema: schema.any(),
},
config: {
schema: schema.any(),
},
secrets: {
schema: schema.any(),
},
},
isSystemActionType: true,
/**
* The executor writes a doc to the
* testing index. The test uses the doc
* to verify that the action is executed
* correctly
*/
async executor({ params, services, actionId }) {
const { index, reference } = params;
if (index == null || reference == null) {
return { status: 'ok', actionId };
}
await services.scopedClusterClient.index({
index,
refresh: 'wait_for',
body: {
params,
reference,
source: 'action:test.system-action-kibana-privileges',
},
});
return { status: 'ok', actionId };
},
};
return result;
}

View file

@ -350,6 +350,62 @@ export function defineRoutes(
});
return res.noContent();
} catch (err) {
if (err.isBoom && err.output.statusCode === 403) {
return res.forbidden({ body: err });
}
return res.badRequest({ body: err });
}
}
);
router.post(
{
path: '/api/alerts_fixture/{id}/bulk_enqueue_actions',
validate: {
params: schema.object({
id: schema.string(),
}),
body: schema.object({
params: schema.recordOf(schema.string(), schema.any()),
}),
},
},
async (
context: RequestHandlerContext,
req: KibanaRequest<any, any, any, any>,
res: KibanaResponseFactory
): Promise<IKibanaResponse<any>> => {
try {
const [, { actions, security, spaces }] = await core.getStartServices();
const actionsClient = await actions.getActionsClientWithRequest(req);
const createAPIKeyResult =
security &&
(await security.authc.apiKeys.grantAsInternalUser(req, {
name: `alerts_fixture:bulk_enqueue_actions:${uuidv4()}`,
role_descriptors: {},
}));
await actionsClient.bulkEnqueueExecution([
{
id: req.params.id,
spaceId: spaces ? spaces.spacesService.getSpaceId(req) : 'default',
executionId: uuidv4(),
apiKey: createAPIKeyResult
? Buffer.from(`${createAPIKeyResult.id}:${createAPIKeyResult.api_key}`).toString(
'base64'
)
: null,
params: req.body.params,
},
]);
return res.noContent();
} catch (err) {
if (err.isBoom && err.output.statusCode === 403) {
return res.forbidden({ body: err });
}
return res.badRequest({ body: err });
}
}

View file

@ -0,0 +1,209 @@
/*
* 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 expect from '@kbn/expect';
import { IValidatedEvent } from '@kbn/event-log-plugin/server';
import { ESTestIndexTool, ES_TEST_INDEX_NAME } from '@kbn/alerting-api-integration-helpers';
import { systemActionScenario, UserAtSpaceScenarios } from '../../../scenarios';
import { getEventLog, getUrlPrefix, ObjectRemover } from '../../../../common/lib';
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
// eslint-disable-next-line import/no-default-export
export default function ({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const supertestWithoutAuth = getService('supertestWithoutAuth');
const es = getService('es');
const retry = getService('retry');
const esTestIndexTool = new ESTestIndexTool(es, retry);
describe('bulk_enqueue', () => {
const objectRemover = new ObjectRemover(supertest);
before(async () => {
await esTestIndexTool.destroy();
await esTestIndexTool.setup();
});
after(async () => {
await esTestIndexTool.destroy();
await objectRemover.removeAll();
});
for (const scenario of [...UserAtSpaceScenarios, systemActionScenario]) {
const { user, space } = scenario;
it(`should handle enqueue request appropriately: ${scenario.id}`, async () => {
const startDate = new Date().toISOString();
const { body: createdAction } = await supertest
.post(`${getUrlPrefix(space.id)}/api/actions/connector`)
.set('kbn-xsrf', 'foo')
.send({
name: 'My action',
connector_type_id: 'test.index-record',
config: {
unencrypted: `This value shouldn't get encrypted`,
},
secrets: {
encrypted: 'This value should be encrypted',
},
})
.expect(200);
objectRemover.add(space.id, createdAction.id, 'action', 'actions');
const connectorId = createdAction.id;
const name = 'My action';
const reference = `actions-enqueue-${scenario.id}:${space.id}:${connectorId}`;
const response = await supertestWithoutAuth
.post(`${getUrlPrefix(space.id)}/api/alerts_fixture/${connectorId}/bulk_enqueue_actions`)
.auth(user.username, user.password)
.set('kbn-xsrf', 'foo')
.send({
params: { reference, index: ES_TEST_INDEX_NAME, message: 'Testing 123' },
});
switch (scenario.id) {
case 'no_kibana_privileges at space1':
case 'space_1_all_alerts_none_actions at space1':
case 'space_1_all at space2':
expect(response.status).to.eql(403);
break;
case 'global_read at space1':
case 'space_1_all at space1':
case 'space_1_all_with_restricted_fixture at space1':
case 'superuser at space1':
case 'system_actions at space1':
expect(response.status).to.eql(204);
await validateEventLog({
spaceId: space.id,
connectorId,
outcome: 'success',
message: `action executed: test.index-record:${connectorId}: ${name}`,
startDate,
});
await esTestIndexTool.waitForDocs('action:test.index-record', reference, 1);
break;
default:
throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);
}
});
it(`should authorize system actions correctly: ${scenario.id}`, async () => {
const startDate = new Date().toISOString();
const connectorId = 'system-connector-test.system-action-kibana-privileges';
const name = 'System action: test.system-action-kibana-privileges';
const reference = `actions-enqueue-${scenario.id}:${space.id}:${connectorId}`;
const response = await supertestWithoutAuth
.post(`${getUrlPrefix(space.id)}/api/alerts_fixture/${connectorId}/bulk_enqueue_actions`)
.auth(user.username, user.password)
.set('kbn-xsrf', 'foo')
.send({
params: { index: ES_TEST_INDEX_NAME, reference },
});
switch (scenario.id) {
case 'no_kibana_privileges at space1':
case 'space_1_all_alerts_none_actions at space1':
case 'space_1_all at space2':
expect(response.status).to.eql(403);
break;
/**
* The users in these scenarios have access
* to Actions but do not have access to
* the system action. They should be able to
* enqueue the action but the execution should fail.
*/
case 'global_read at space1':
case 'space_1_all at space1':
case 'space_1_all_with_restricted_fixture at space1':
expect(response.status).to.eql(204);
await validateEventLog({
spaceId: space.id,
connectorId,
outcome: 'failure',
message: `action execution failure: test.system-action-kibana-privileges:${connectorId}: ${name}`,
errorMessage: 'Unauthorized to execute actions',
startDate,
});
break;
/**
* The users in these scenarios have access
* to Actions and to the system action. They should be able to
* enqueue the action and the execution should succeed.
*/
case 'superuser at space1':
case 'system_actions at space1':
expect(response.status).to.eql(204);
await validateEventLog({
spaceId: space.id,
connectorId,
outcome: 'success',
message: `action executed: test.system-action-kibana-privileges:${connectorId}: ${name}`,
startDate,
});
await esTestIndexTool.waitForDocs(
'action:test.system-action-kibana-privileges',
reference,
1
);
break;
default:
throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);
}
});
}
});
interface ValidateEventLogParams {
spaceId: string;
connectorId: string;
outcome: string;
message: string;
startDate: string;
errorMessage?: string;
}
const validateEventLog = async (params: ValidateEventLogParams): Promise<void> => {
const { spaceId, connectorId, outcome, message, startDate, errorMessage } = params;
const events: IValidatedEvent[] = await retry.try(async () => {
const events_ = await getEventLog({
getService,
spaceId,
type: 'action',
id: connectorId,
provider: 'actions',
actions: new Map([['execute', { gte: 1 }]]),
});
const filteredEvents = events_.filter((event) => event!['@timestamp']! >= startDate);
if (filteredEvents.length < 1) throw new Error('no recent events found yet');
return filteredEvents;
});
expect(events.length).to.be(1);
const event = events[0];
expect(event?.message).to.eql(message);
expect(event?.event?.outcome).to.eql(outcome);
if (errorMessage) {
expect(event?.error?.message).to.eql(errorMessage);
}
};
}

View file

@ -0,0 +1,218 @@
/*
* 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 expect from '@kbn/expect';
import { IValidatedEvent } from '@kbn/event-log-plugin/server';
import { ESTestIndexTool, ES_TEST_INDEX_NAME } from '@kbn/alerting-api-integration-helpers';
import { ActionExecutionSourceType } from '@kbn/actions-plugin/server/types';
import { systemActionScenario, UserAtSpaceScenarios } from '../../../scenarios';
import { getEventLog, getUrlPrefix, ObjectRemover } from '../../../../common/lib';
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
// eslint-disable-next-line import/no-default-export
export default function ({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const supertestWithoutAuth = getService('supertestWithoutAuth');
const es = getService('es');
const retry = getService('retry');
const esTestIndexTool = new ESTestIndexTool(es, retry);
describe('enqueue', () => {
const objectRemover = new ObjectRemover(supertest);
before(async () => {
await esTestIndexTool.destroy();
await esTestIndexTool.setup();
});
after(async () => {
await esTestIndexTool.destroy();
await objectRemover.removeAll();
});
for (const scenario of [...UserAtSpaceScenarios, systemActionScenario]) {
const { user, space } = scenario;
it(`should handle enqueue request appropriately: ${scenario.id}`, async () => {
const startDate = new Date().toISOString();
const { body: createdAction } = await supertest
.post(`${getUrlPrefix(space.id)}/api/actions/connector`)
.set('kbn-xsrf', 'foo')
.send({
name: 'My action',
connector_type_id: 'test.index-record',
config: {
unencrypted: `This value shouldn't get encrypted`,
},
secrets: {
encrypted: 'This value should be encrypted',
},
})
.expect(200);
objectRemover.add(space.id, createdAction.id, 'action', 'actions');
const connectorId = createdAction.id;
const name = 'My action';
const reference = `actions-enqueue-${scenario.id}:${space.id}:${connectorId}`;
const response = await supertestWithoutAuth
.post(`${getUrlPrefix(space.id)}/api/alerts_fixture/${connectorId}/enqueue_action`)
.auth(user.username, user.password)
.set('kbn-xsrf', 'foo')
.send({
params: { reference, index: ES_TEST_INDEX_NAME, message: 'Testing 123' },
});
switch (scenario.id) {
case 'no_kibana_privileges at space1':
case 'space_1_all_alerts_none_actions at space1':
case 'space_1_all at space2':
expect(response.status).to.eql(403);
break;
case 'global_read at space1':
case 'space_1_all at space1':
case 'space_1_all_with_restricted_fixture at space1':
case 'superuser at space1':
case 'system_actions at space1':
expect(response.status).to.eql(204);
await validateEventLog({
spaceId: space.id,
connectorId,
outcome: 'success',
message: `action executed: test.index-record:${connectorId}: ${name}`,
source: ActionExecutionSourceType.HTTP_REQUEST,
startDate,
});
await esTestIndexTool.waitForDocs('action:test.index-record', reference, 1);
break;
default:
throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);
}
});
it(`should authorize system actions correctly: ${scenario.id}`, async () => {
const startDate = new Date().toISOString();
const connectorId = 'system-connector-test.system-action-kibana-privileges';
const name = 'System action: test.system-action-kibana-privileges';
const reference = `actions-enqueue-${scenario.id}:${space.id}:${connectorId}`;
const response = await supertestWithoutAuth
.post(`${getUrlPrefix(space.id)}/api/alerts_fixture/${connectorId}/enqueue_action`)
.auth(user.username, user.password)
.set('kbn-xsrf', 'foo')
.send({
params: { index: ES_TEST_INDEX_NAME, reference },
});
switch (scenario.id) {
case 'no_kibana_privileges at space1':
case 'space_1_all_alerts_none_actions at space1':
case 'space_1_all at space2':
expect(response.status).to.eql(403);
break;
/**
* The users in these scenarios have access
* to Actions but do not have access to
* the system action. They should be able to
* enqueue the action but the execution should fail.
*/
case 'global_read at space1':
case 'space_1_all at space1':
case 'space_1_all_with_restricted_fixture at space1':
expect(response.status).to.eql(204);
await validateEventLog({
spaceId: space.id,
connectorId,
outcome: 'failure',
message: `action execution failure: test.system-action-kibana-privileges:${connectorId}: ${name}`,
errorMessage: 'Unauthorized to execute actions',
source: ActionExecutionSourceType.HTTP_REQUEST,
startDate,
});
break;
/**
* The users in these scenarios have access
* to Actions and to the system action. They should be able to
* enqueue the action and the execution should succeed.
*/
case 'superuser at space1':
case 'system_actions at space1':
expect(response.status).to.eql(204);
await validateEventLog({
spaceId: space.id,
connectorId,
outcome: 'success',
message: `action executed: test.system-action-kibana-privileges:${connectorId}: ${name}`,
source: ActionExecutionSourceType.HTTP_REQUEST,
startDate,
});
await esTestIndexTool.waitForDocs(
'action:test.system-action-kibana-privileges',
reference,
1
);
break;
default:
throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);
}
});
}
});
interface ValidateEventLogParams {
spaceId: string;
connectorId: string;
outcome: string;
message: string;
startDate: string;
errorMessage?: string;
source?: string;
}
const validateEventLog = async (params: ValidateEventLogParams): Promise<void> => {
const { spaceId, connectorId, outcome, message, startDate, errorMessage, source } = params;
const events: IValidatedEvent[] = await retry.try(async () => {
const events_ = await getEventLog({
getService,
spaceId,
type: 'action',
id: connectorId,
provider: 'actions',
actions: new Map([['execute', { gte: 1 }]]),
});
const filteredEvents = events_.filter((event) => event!['@timestamp']! >= startDate);
if (filteredEvents.length < 1) throw new Error('no recent events found yet');
return filteredEvents;
});
expect(events.length).to.be(1);
const event = events[0];
expect(event?.message).to.eql(message);
expect(event?.event?.outcome).to.eql(outcome);
if (errorMessage) {
expect(event?.error?.message).to.eql(errorMessage);
}
if (source) {
expect(event?.kibana?.action?.execution?.source).to.eql(source.toLowerCase());
}
};
}

View file

@ -9,7 +9,7 @@ import expect from '@kbn/expect';
import { IValidatedEvent, nanosToMillis } from '@kbn/event-log-plugin/server';
import { ESTestIndexTool, ES_TEST_INDEX_NAME } from '@kbn/alerting-api-integration-helpers';
import { ActionExecutionSourceType } from '@kbn/actions-plugin/server/lib/action_execution_source';
import { UserAtSpaceScenarios } from '../../../scenarios';
import { systemActionScenario, UserAtSpaceScenarios } from '../../../scenarios';
import { getUrlPrefix, ObjectRemover, getEventLog } from '../../../../common/lib';
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
@ -37,7 +37,7 @@ export default function ({ getService }: FtrProviderContext) {
await objectRemover.removeAll();
});
for (const scenario of UserAtSpaceScenarios) {
for (const scenario of [...UserAtSpaceScenarios, systemActionScenario]) {
const { user, space } = scenario;
describe(scenario.id, () => {
it('should handle execute request appropriately', async () => {
@ -85,6 +85,7 @@ export default function ({ getService }: FtrProviderContext) {
case 'superuser at space1':
case 'space_1_all at space1':
case 'space_1_all_with_restricted_fixture at space1':
case 'system_actions at space1':
expect(response.statusCode).to.eql(200);
expect(response.body).to.be.an('object');
const searchResult = await esTestIndexTool.search(
@ -169,6 +170,7 @@ export default function ({ getService }: FtrProviderContext) {
break;
case 'global_read at space1':
case 'superuser at space1':
case 'system_actions at space1':
expect(response.statusCode).to.eql(404);
expect(response.body).to.eql({
statusCode: 404,
@ -240,6 +242,7 @@ export default function ({ getService }: FtrProviderContext) {
case 'superuser at space1':
case 'space_1_all at space1':
case 'space_1_all_with_restricted_fixture at space1':
case 'system_actions at space1':
expect(response.statusCode).to.eql(200);
expect(response.body).to.be.an('object');
const searchResult = await esTestIndexTool.search(
@ -294,6 +297,7 @@ export default function ({ getService }: FtrProviderContext) {
case 'superuser at space1':
case 'space_1_all at space1':
case 'space_1_all_with_restricted_fixture at space1':
case 'system_actions at space1':
expect(response.statusCode).to.eql(404);
expect(response.body).to.eql({
statusCode: 404,
@ -321,6 +325,7 @@ export default function ({ getService }: FtrProviderContext) {
case 'superuser at space1':
case 'space_1_all at space1':
case 'space_1_all_with_restricted_fixture at space1':
case 'system_actions at space1':
expect(response.statusCode).to.eql(400);
expect(response.body).to.eql({
statusCode: 400,
@ -399,6 +404,7 @@ export default function ({ getService }: FtrProviderContext) {
case 'superuser at space1':
case 'space_1_all at space1':
case 'space_1_all_with_restricted_fixture at space1':
case 'system_actions at space1':
expect(response.statusCode).to.eql(200);
break;
default:
@ -448,6 +454,7 @@ export default function ({ getService }: FtrProviderContext) {
case 'global_read at space1':
case 'space_1_all at space1':
case 'space_1_all_with_restricted_fixture at space1':
case 'system_actions at space1':
expect(response.statusCode).to.eql(200);
searchResult = await esTestIndexTool.search('action:test.authorization', reference);
expect(searchResult.body.hits.total.value).to.eql(1);
@ -493,6 +500,69 @@ export default function ({ getService }: FtrProviderContext) {
throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);
}
});
it('should authorize system actions correctly', async () => {
const startDate = new Date().toISOString();
const connectorId = 'system-connector-test.system-action-kibana-privileges';
const name = 'System action: test.system-action-kibana-privileges';
const reference = `actions-enqueue-${scenario.id}:${space.id}:${connectorId}`;
const response = await supertestWithoutAuth
.post(`${getUrlPrefix(space.id)}/api/actions/connector/${connectorId}/_execute`)
.auth(user.username, user.password)
.set('kbn-xsrf', 'foo')
.send({
params: { index: ES_TEST_INDEX_NAME, reference },
});
switch (scenario.id) {
/**
* The users in these scenarios may have access
* to Actions but do not have access to
* the system action. They should not be able to
* to execute even if they have access to Actions.
*/
case 'no_kibana_privileges at space1':
case 'space_1_all_alerts_none_actions at space1':
case 'space_1_all at space2':
case 'global_read at space1':
case 'space_1_all at space1':
case 'space_1_all_with_restricted_fixture at space1':
expect(response.statusCode).to.eql(403);
expect(response.body).to.eql({
statusCode: 403,
error: 'Forbidden',
message: 'Unauthorized to execute actions',
});
break;
/**
* The users in these scenarios have access
* to Actions and to the system action. They should be able to
* execute.
*/
case 'superuser at space1':
case 'system_actions at space1':
expect(response.statusCode).to.eql(200);
await validateSystemEventLog({
spaceId: space.id,
connectorId,
startDate,
outcome: 'success',
message: `action executed: test.system-action-kibana-privileges:${connectorId}: ${name}`,
source: ActionExecutionSourceType.HTTP_REQUEST,
});
await esTestIndexTool.waitForDocs(
'action:test.system-action-kibana-privileges',
reference,
1
);
break;
default:
throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);
}
});
});
}
});
@ -505,10 +575,20 @@ export default function ({ getService }: FtrProviderContext) {
message: string;
errorMessage?: string;
source?: string;
spaceAgnostic?: boolean;
}
async function validateEventLog(params: ValidateEventLogParams): Promise<void> {
const { spaceId, connectorId, actionTypeId, outcome, message, errorMessage, source } = params;
const {
spaceId,
connectorId,
actionTypeId,
outcome,
message,
errorMessage,
source,
spaceAgnostic,
} = params;
const events: IValidatedEvent[] = await retry.try(async () => {
return await getEventLog({
@ -521,7 +601,6 @@ export default function ({ getService }: FtrProviderContext) {
['execute-start', { equal: 1 }],
['execute', { equal: 1 }],
]),
// filter: 'event.action:(execute)',
});
});
@ -555,6 +634,7 @@ export default function ({ getService }: FtrProviderContext) {
id: connectorId,
namespace: 'space1',
type_id: actionTypeId,
...(spaceAgnostic ? { space_agnostic: true } : {}),
},
]);
expect(startExecuteEvent?.kibana?.saved_objects).to.eql(executeEvent?.kibana?.saved_objects);
@ -569,43 +649,42 @@ export default function ({ getService }: FtrProviderContext) {
if (errorMessage) {
expect(executeEvent?.error?.message).to.eql(errorMessage);
}
// const event = events[0];
// const duration = event?.event?.duration;
// const eventStart = Date.parse(event?.event?.start || 'undefined');
// const eventEnd = Date.parse(event?.event?.end || 'undefined');
// const dateNow = Date.now();
// expect(typeof duration).to.be('number');
// expect(eventStart).to.be.ok();
// expect(eventEnd).to.be.ok();
// const durationDiff = Math.abs(
// Math.round(duration! / NANOS_IN_MILLIS) - (eventEnd - eventStart)
// );
// // account for rounding errors
// expect(durationDiff < 1).to.equal(true);
// expect(eventStart <= eventEnd).to.equal(true);
// expect(eventEnd <= dateNow).to.equal(true);
// expect(event?.event?.outcome).to.equal(outcome);
// expect(event?.kibana?.saved_objects).to.eql([
// {
// rel: 'primary',
// type: 'action',
// id: connectorId,
// type_id: actionTypeId,
// namespace: spaceId,
// },
// ]);
// expect(event?.message).to.eql(message);
// if (errorMessage) {
// expect(event?.error?.message).to.eql(errorMessage);
// }
}
const validateSystemEventLog = async (
params: Omit<ValidateEventLogParams, 'actionTypeId' | 'spaceAgnostic'> & { startDate: string }
): Promise<void> => {
const { spaceId, connectorId, outcome, message, startDate, errorMessage, source } = params;
const events: IValidatedEvent[] = await retry.try(async () => {
const events_ = await getEventLog({
getService,
spaceId,
type: 'action',
id: connectorId,
provider: 'actions',
actions: new Map([['execute', { gte: 1 }]]),
});
const filteredEvents = events_.filter((event) => event!['@timestamp']! >= startDate);
if (filteredEvents.length < 1) throw new Error('no recent events found yet');
return filteredEvents;
});
expect(events.length).to.be(1);
const event = events[0];
expect(event?.message).to.eql(message);
expect(event?.event?.outcome).to.eql(outcome);
if (errorMessage) {
expect(event?.error?.message).to.eql(errorMessage);
}
if (source) {
expect(event?.kibana?.action?.execution?.source).to.eql(source.toLowerCase());
}
};
}

View file

@ -134,6 +134,15 @@ export default function getAllActionTests({ getService }: FtrProviderContext) {
name: 'System action: test.system-action',
referenced_by_count: 0,
},
{
connector_type_id: 'test.system-action-kibana-privileges',
id: 'system-connector-test.system-action-kibana-privileges',
is_deprecated: false,
is_preconfigured: false,
is_system_action: true,
name: 'System action: test.system-action-kibana-privileges',
referenced_by_count: 0,
},
{
id: 'custom-system-abc-connector',
is_preconfigured: true,
@ -303,6 +312,15 @@ export default function getAllActionTests({ getService }: FtrProviderContext) {
name: 'System action: test.system-action',
referenced_by_count: 0,
},
{
connector_type_id: 'test.system-action-kibana-privileges',
id: 'system-connector-test.system-action-kibana-privileges',
is_deprecated: false,
is_preconfigured: false,
is_system_action: true,
name: 'System action: test.system-action-kibana-privileges',
referenced_by_count: 0,
},
{
id: 'custom-system-abc-connector',
is_preconfigured: true,
@ -435,6 +453,15 @@ export default function getAllActionTests({ getService }: FtrProviderContext) {
name: 'System action: test.system-action',
referenced_by_count: 0,
},
{
connector_type_id: 'test.system-action-kibana-privileges',
id: 'system-connector-test.system-action-kibana-privileges',
is_deprecated: false,
is_preconfigured: false,
is_system_action: true,
name: 'System action: test.system-action-kibana-privileges',
referenced_by_count: 0,
},
{
id: 'custom-system-abc-connector',
is_preconfigured: true,

View file

@ -18,6 +18,7 @@ export default function connectorsTests({ loadTestFile, getService }: FtrProvide
after(async () => {
await tearDown(getService);
});
loadTestFile(require.resolve('./connector_types/oauth_access_token'));
loadTestFile(require.resolve('./connector_types/cases_webhook'));
loadTestFile(require.resolve('./connector_types/jira'));
@ -47,10 +48,13 @@ export default function connectorsTests({ loadTestFile, getService }: FtrProvide
loadTestFile(require.resolve('./get'));
loadTestFile(require.resolve('./connector_types'));
loadTestFile(require.resolve('./update'));
loadTestFile(require.resolve('./enqueue'));
loadTestFile(require.resolve('./bulk_enqueue'));
/**
* Sub action framework
*/
// loadTestFile(require.resolve('./sub_action_framework'));
loadTestFile(require.resolve('./sub_action_framework'));
});
}

View file

@ -154,6 +154,42 @@ const Space1AllWithRestrictedFixture: User = {
},
};
/**
* This user is needed to test system actions.
* In x-pack/test/alerting_api_integration/common/plugins/alerts/server/action_types.ts
* we registered a system action type which requires access to Cases. This user has
* access to Cases only in the Stack Management. The tests use this user to
* execute the system action and verify that the authorization is performed
* as expected
*/
const CasesAll: User = {
username: 'cases_all',
fullName: 'cases_all',
password: 'cases_all',
role: {
name: 'cases_all_role',
elasticsearch: {
indices: [
{
names: [`${ES_TEST_INDEX_NAME}*`],
privileges: ['all'],
},
],
},
kibana: [
{
feature: {
generalCases: ['all'],
actions: ['all'],
alertsFixture: ['all'],
alertsRestrictedFixture: ['all'],
},
spaces: ['*'],
},
],
},
};
export const Users: User[] = [
NoKibanaPrivileges,
Superuser,
@ -161,6 +197,7 @@ export const Users: User[] = [
Space1All,
Space1AllWithRestrictedFixture,
Space1AllAlertingNoneActions,
CasesAll,
];
const Space1: Space = {
@ -254,6 +291,16 @@ const Space1AllAtSpace2: Space1AllAtSpace2 = {
space: Space2,
};
interface SystemActionSpace1 extends Scenario {
id: 'system_actions at space1';
}
export const systemActionScenario: SystemActionSpace1 = {
id: 'system_actions at space1',
user: CasesAll,
space: Space1,
};
export const UserAtSpaceScenarios: [
NoKibanaPrivilegesAtSpace1,
SuperuserAtSpace1,

View file

@ -0,0 +1,90 @@
/*
* 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 expect from '@kbn/expect';
import { ESTestIndexTool, ES_TEST_INDEX_NAME } from '@kbn/alerting-api-integration-helpers';
import { Spaces } from '../../scenarios';
import { getUrlPrefix, ObjectRemover } from '../../../common/lib';
import { FtrProviderContext } from '../../../common/ftr_provider_context';
// eslint-disable-next-line import/no-default-export
export default function ({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const es = getService('es');
const retry = getService('retry');
const esTestIndexTool = new ESTestIndexTool(es, retry);
describe('bulk_enqueue', () => {
const objectRemover = new ObjectRemover(supertest);
before(async () => {
await esTestIndexTool.destroy();
await esTestIndexTool.setup();
});
after(async () => {
await esTestIndexTool.destroy();
await objectRemover.removeAll();
});
it('should handle bulk_enqueue request appropriately', async () => {
const { body: createdAction } = await supertest
.post(`${getUrlPrefix(Spaces.space1.id)}/api/actions/connector`)
.set('kbn-xsrf', 'foo')
.send({
name: 'My action',
connector_type_id: 'test.index-record',
config: {
unencrypted: `This value shouldn't get encrypted`,
},
secrets: {
encrypted: 'This value should be encrypted',
},
})
.expect(200);
objectRemover.add(Spaces.space1.id, createdAction.id, 'action', 'actions');
const reference = `actions-enqueue-1:${Spaces.space1.id}:${createdAction.id}`;
const response = await supertest
.post(
`${getUrlPrefix(Spaces.space1.id)}/api/alerts_fixture/${
createdAction.id
}/bulk_enqueue_actions`
)
.set('kbn-xsrf', 'foo')
.send({
params: { reference, index: ES_TEST_INDEX_NAME, message: 'Testing 123' },
});
expect(response.status).to.eql(204);
await esTestIndexTool.waitForDocs('action:test.index-record', reference, 1);
});
it('should enqueue system actions correctly', async () => {
const connectorId = 'system-connector-test.system-action-kibana-privileges';
const reference = `actions-enqueue-1:${Spaces.space1.id}:${connectorId}`;
const response = await supertest
.post(
`${getUrlPrefix(Spaces.space1.id)}/api/alerts_fixture/${connectorId}/bulk_enqueue_actions`
)
.set('kbn-xsrf', 'foo')
.send({
params: { index: ES_TEST_INDEX_NAME, reference },
});
expect(response.status).to.eql(204);
await esTestIndexTool.waitForDocs(
'action:test.system-action-kibana-privileges',
reference,
1
);
});
});
}

View file

@ -201,5 +201,25 @@ export default function ({ getService }: FtrProviderContext) {
expect(total).to.eql(0);
});
});
it('should enqueue system actions correctly', async () => {
const connectorId = 'system-connector-test.system-action-kibana-privileges';
const reference = `actions-enqueue-1:${Spaces.space1.id}:${connectorId}`;
const response = await supertest
.post(`${getUrlPrefix(Spaces.space1.id)}/api/alerts_fixture/${connectorId}/enqueue_action`)
.set('kbn-xsrf', 'foo')
.send({
params: { index: ES_TEST_INDEX_NAME, reference },
});
expect(response.status).to.eql(204);
await esTestIndexTool.waitForDocs(
'action:test.system-action-kibana-privileges',
reference,
1
);
});
});
}

View file

@ -329,6 +329,56 @@ export default function ({ getService }: FtrProviderContext) {
});
});
});
it('should execute system actions correctly', async () => {
const connectorId = 'system-connector-test.system-action';
const name = 'System action: test.system-action';
const response = await supertest
.post(`${getUrlPrefix(Spaces.space1.id)}/api/actions/connector/${connectorId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: {},
});
expect(response.status).to.eql(200);
await validateEventLog({
spaceId: Spaces.space1.id,
actionId: connectorId,
actionTypeId: 'test.system-action',
outcome: 'success',
message: `action executed: test.system-action:${connectorId}: ${name}`,
startMessage: `action started: test.system-action:${connectorId}: ${name}`,
source: ActionExecutionSourceType.HTTP_REQUEST,
spaceAgnostic: true,
});
});
it('should execute system actions with kibana privileges correctly', async () => {
const connectorId = 'system-connector-test.system-action-kibana-privileges';
const name = 'System action: test.system-action-kibana-privileges';
const response = await supertest
.post(`${getUrlPrefix(Spaces.space1.id)}/api/actions/connector/${connectorId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: {},
});
expect(response.status).to.eql(200);
await validateEventLog({
spaceId: Spaces.space1.id,
actionId: connectorId,
actionTypeId: 'test.system-action-kibana-privileges',
outcome: 'success',
message: `action executed: test.system-action-kibana-privileges:${connectorId}: ${name}`,
startMessage: `action started: test.system-action-kibana-privileges:${connectorId}: ${name}`,
source: ActionExecutionSourceType.HTTP_REQUEST,
spaceAgnostic: true,
});
});
});
interface ValidateEventLogParams {
@ -340,6 +390,7 @@ export default function ({ getService }: FtrProviderContext) {
errorMessage?: string;
startMessage?: string;
source?: string;
spaceAgnostic?: boolean;
}
async function validateEventLog(params: ValidateEventLogParams): Promise<void> {
@ -352,6 +403,7 @@ export default function ({ getService }: FtrProviderContext) {
startMessage,
errorMessage,
source,
spaceAgnostic,
} = params;
const events: IValidatedEvent[] = await retry.try(async () => {
@ -398,6 +450,7 @@ export default function ({ getService }: FtrProviderContext) {
id: actionId,
namespace: 'space1',
type_id: actionTypeId,
...(spaceAgnostic ? { space_agnostic: true } : {}),
},
]);
expect(startExecuteEvent?.kibana?.saved_objects).to.eql(executeEvent?.kibana?.saved_objects);

View file

@ -123,6 +123,15 @@ export default function getAllActionTests({ getService }: FtrProviderContext) {
name: 'System action: test.system-action',
referenced_by_count: 0,
},
{
connector_type_id: 'test.system-action-kibana-privileges',
id: 'system-connector-test.system-action-kibana-privileges',
is_deprecated: false,
is_preconfigured: false,
is_system_action: true,
name: 'System action: test.system-action-kibana-privileges',
referenced_by_count: 0,
},
{
id: 'custom-system-abc-connector',
is_preconfigured: true,
@ -244,6 +253,15 @@ export default function getAllActionTests({ getService }: FtrProviderContext) {
name: 'System action: test.system-action',
referenced_by_count: 0,
},
{
connector_type_id: 'test.system-action-kibana-privileges',
id: 'system-connector-test.system-action-kibana-privileges',
is_deprecated: false,
is_preconfigured: false,
is_system_action: true,
name: 'System action: test.system-action-kibana-privileges',
referenced_by_count: 0,
},
{
id: 'custom-system-abc-connector',
is_preconfigured: true,
@ -379,6 +397,15 @@ export default function getAllActionTests({ getService }: FtrProviderContext) {
name: 'System action: test.system-action',
referencedByCount: 0,
},
{
actionTypeId: 'test.system-action-kibana-privileges',
id: 'system-connector-test.system-action-kibana-privileges',
isDeprecated: false,
isPreconfigured: false,
isSystemAction: true,
name: 'System action: test.system-action-kibana-privileges',
referencedByCount: 0,
},
{
id: 'custom-system-abc-connector',
isPreconfigured: true,

View file

@ -23,6 +23,7 @@ export default function actionsTests({ loadTestFile, getService }: FtrProviderCo
loadTestFile(require.resolve('./monitoring_collection'));
loadTestFile(require.resolve('./execute'));
loadTestFile(require.resolve('./enqueue'));
loadTestFile(require.resolve('./bulk_enqueue'));
loadTestFile(require.resolve('./connector_types/stack/email'));
loadTestFile(require.resolve('./connector_types/stack/email_html'));
loadTestFile(require.resolve('./connector_types/stack/es_index'));