diff --git a/x-pack/plugins/actions/server/action_type_registry.mock.ts b/x-pack/plugins/actions/server/action_type_registry.mock.ts index 532b192001e7a..399bf6ed22684 100644 --- a/x-pack/plugins/actions/server/action_type_registry.mock.ts +++ b/x-pack/plugins/actions/server/action_type_registry.mock.ts @@ -19,6 +19,7 @@ const createActionTypeRegistryMock = () => { isActionExecutable: jest.fn(), isSystemActionType: jest.fn(), getUtils: jest.fn(), + getSystemActionKibanaPrivileges: jest.fn(), }; return mocked; }; diff --git a/x-pack/plugins/actions/server/action_type_registry.test.ts b/x-pack/plugins/actions/server/action_type_registry.test.ts index ff1e7f9b59a53..915f5ed047052 100644 --- a/x-pack/plugins/actions/server/action_type_registry.test.ts +++ b/x-pack/plugins/actions/server/action_type_registry.test.ts @@ -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' } }); + }); + }); }); diff --git a/x-pack/plugins/actions/server/action_type_registry.ts b/x-pack/plugins/actions/server/action_type_registry.ts index 47e43b1fa89b1..5d96b0d6b9761 100644 --- a/x-pack/plugins/actions/server/action_type_registry.ts +++ b/x-pack/plugins/actions/server/action_type_registry.ts @@ -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( + 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, diff --git a/x-pack/plugins/actions/server/actions_client.test.ts b/x-pack/plugins/actions/server/actions_client.test.ts index a18a8adada424..de8bcc5ce82f0 100644 --- a/x-pack/plugins/actions/server/actions_client.test.ts +++ b/x-pack/plugins/actions/server/actions_client.test.ts @@ -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' }); }); }); diff --git a/x-pack/plugins/actions/server/actions_client.ts b/x-pack/plugins/actions/server/actions_client.ts index db25e302a4ac0..17efbc1b53e3e 100644 --- a/x-pack/plugins/actions/server/actions_client.ts +++ b/x-pack/plugins/actions/server/actions_client.ts @@ -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 { 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 { 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 { 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 { 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({ diff --git a/x-pack/plugins/actions/server/authorization/actions_authorization.test.ts b/x-pack/plugins/actions/server/authorization/actions_authorization.test.ts index 09310c398bc53..fa65b06777f98 100644 --- a/x-pack/plugins/actions/server/authorization/actions_authorization.test.ts +++ b/x-pack/plugins/actions/server/authorization/actions_authorization.test.ts @@ -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 + > = 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', + ], + }); + }); }); diff --git a/x-pack/plugins/actions/server/authorization/actions_authorization.ts b/x-pack/plugins/actions/server/authorization/actions_authorization.ts index f09670c216613..34eec819b431b 100644 --- a/x-pack/plugins/actions/server/authorization/actions_authorization.ts +++ b/x-pack/plugins/actions/server/authorization/actions_authorization.ts @@ -28,15 +28,14 @@ export interface ConstructorOptions { authorizationMode?: AuthorizationMode; } -const operationAlias: Record< - string, - (authorization: SecurityPluginSetup['authz']) => string | string[] -> = { +const operationAlias: Record 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( diff --git a/x-pack/plugins/actions/server/lib/action_executor.test.ts b/x-pack/plugins/actions/server/lib/action_executor.test.ts index 7307b66ee2de9..f0a11b9f90042 100644 --- a/x-pack/plugins/actions/server/lib/action_executor.test.ts +++ b/x-pack/plugins/actions/server/lib/action_executor.test.ts @@ -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 = 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 = { + 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 = { + 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 = { + 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 = { 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() }, diff --git a/x-pack/plugins/actions/server/lib/action_executor.ts b/x-pack/plugins/actions/server/lib/action_executor.ts index e9a8036173635..d5257f4eb8450 100644 --- a/x-pack/plugins/actions/server/lib/action_executor.ts +++ b/x-pack/plugins/actions/server/lib/action_executor.ts @@ -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; 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; + 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, + }); + } +}; diff --git a/x-pack/plugins/actions/server/lib/errors/action_execution_error.ts b/x-pack/plugins/actions/server/lib/errors/action_execution_error.ts index ad43008ef8e20..80ed18f3d3d31 100644 --- a/x-pack/plugins/actions/server/lib/errors/action_execution_error.ts +++ b/x-pack/plugins/actions/server/lib/errors/action_execution_error.ts @@ -9,6 +9,7 @@ import { ActionTypeExecutorResult } from '../../types'; export enum ActionExecutionErrorReason { Validation = 'validation', + Authorization = 'authorization', } export class ActionExecutionError extends Error { diff --git a/x-pack/plugins/actions/server/lib/task_runner_factory.test.ts b/x-pack/plugins/actions/server/lib/task_runner_factory.test.ts index e9897a6d4f8d1..e8c66cff784c9 100644 --- a/x-pack/plugins/actions/server/lib/task_runner_factory.test.ts +++ b/x-pack/plugins/actions/server/lib/task_runner_factory.test.ts @@ -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, diff --git a/x-pack/plugins/actions/server/plugin.ts b/x-pack/plugins/actions/server/plugin.ts index bc29c3e9ad246..85233356ffda8 100644 --- a/x-pack/plugins/actions/server/plugin.ts +++ b/x-pack/plugins/actions/server/plugin.ts @@ -513,6 +513,9 @@ export class ActionsPlugin implements Plugin 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; executor: ExecutorType; } diff --git a/x-pack/plugins/alerting/server/rules_client/methods/bulk_edit.ts b/x-pack/plugins/alerting/server/rules_client/methods/bulk_edit.ts index da812bf079487..b87d71aa275bc 100644 --- a/x-pack/plugins/alerting/server/rules_client/methods/bulk_edit.ts +++ b/x-pack/plugins/alerting/server/rules_client/methods/bulk_edit.ts @@ -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}`); diff --git a/x-pack/plugins/alerting/server/rules_client/methods/bulk_enable.ts b/x-pack/plugins/alerting/server/rules_client/methods/bulk_enable.ts index 570ad3d5abc56..42b20e65585c1 100644 --- a/x-pack/plugins/alerting/server/rules_client/methods/bulk_enable.ts +++ b/x-pack/plugins/alerting/server/rules_client/methods/bulk_enable.ts @@ -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}`); } diff --git a/x-pack/plugins/alerting/server/rules_client/methods/enable.ts b/x-pack/plugins/alerting/server/rules_client/methods/enable.ts index 0cd42f282bcbc..948f254fe462a 100644 --- a/x-pack/plugins/alerting/server/rules_client/methods/enable.ts +++ b/x-pack/plugins/alerting/server/rules_client/methods/enable.ts @@ -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( diff --git a/x-pack/plugins/alerting/server/rules_client/methods/mute_all.ts b/x-pack/plugins/alerting/server/rules_client/methods/mute_all.ts index 4ac6ad207fdc7..dbc00b2135176 100644 --- a/x-pack/plugins/alerting/server/rules_client/methods/mute_all.ts +++ b/x-pack/plugins/alerting/server/rules_client/methods/mute_all.ts @@ -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( diff --git a/x-pack/plugins/alerting/server/rules_client/methods/mute_instance.ts b/x-pack/plugins/alerting/server/rules_client/methods/mute_instance.ts index 67e78b9851945..5f37988b7b718 100644 --- a/x-pack/plugins/alerting/server/rules_client/methods/mute_instance.ts +++ b/x-pack/plugins/alerting/server/rules_client/methods/mute_instance.ts @@ -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( diff --git a/x-pack/plugins/alerting/server/rules_client/methods/run_soon.ts b/x-pack/plugins/alerting/server/rules_client/methods/run_soon.ts index d683b5fbafe4f..106f12ef50f6f 100644 --- a/x-pack/plugins/alerting/server/rules_client/methods/run_soon.ts +++ b/x-pack/plugins/alerting/server/rules_client/methods/run_soon.ts @@ -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( diff --git a/x-pack/plugins/alerting/server/rules_client/methods/snooze.ts b/x-pack/plugins/alerting/server/rules_client/methods/snooze.ts index 04585bca002b0..8bfd19bc9c583 100644 --- a/x-pack/plugins/alerting/server/rules_client/methods/snooze.ts +++ b/x-pack/plugins/alerting/server/rules_client/methods/snooze.ts @@ -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( diff --git a/x-pack/plugins/alerting/server/rules_client/methods/unmute_all.ts b/x-pack/plugins/alerting/server/rules_client/methods/unmute_all.ts index 80819de2b6cc2..d5cd81b2664d6 100644 --- a/x-pack/plugins/alerting/server/rules_client/methods/unmute_all.ts +++ b/x-pack/plugins/alerting/server/rules_client/methods/unmute_all.ts @@ -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( diff --git a/x-pack/plugins/alerting/server/rules_client/methods/unmute_instance.ts b/x-pack/plugins/alerting/server/rules_client/methods/unmute_instance.ts index 714e5c0a4f8e4..86c894a7babd0 100644 --- a/x-pack/plugins/alerting/server/rules_client/methods/unmute_instance.ts +++ b/x-pack/plugins/alerting/server/rules_client/methods/unmute_instance.ts @@ -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( diff --git a/x-pack/plugins/alerting/server/rules_client/methods/unsnooze.ts b/x-pack/plugins/alerting/server/rules_client/methods/unsnooze.ts index 67e8d76e649b4..59d0ea62eb3ff 100644 --- a/x-pack/plugins/alerting/server/rules_client/methods/unsnooze.ts +++ b/x-pack/plugins/alerting/server/rules_client/methods/unsnooze.ts @@ -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( diff --git a/x-pack/plugins/alerting/server/rules_client/methods/update_api_key.ts b/x-pack/plugins/alerting/server/rules_client/methods/update_api_key.ts index 4fefb5a8b367e..d8d78264c448b 100644 --- a/x-pack/plugins/alerting/server/rules_client/methods/update_api_key.ts +++ b/x-pack/plugins/alerting/server/rules_client/methods/update_api_key.ts @@ -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( diff --git a/x-pack/plugins/alerting/server/rules_client/tests/enable.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/enable.test.ts index d7175e9a47b89..d123469527ad5 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/enable.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/enable.test.ts @@ -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 () => { diff --git a/x-pack/plugins/alerting/server/rules_client/tests/mute_all.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/mute_all.test.ts index fde2534ec6e21..0c9c34f1cbabe 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/mute_all.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/mute_all.test.ts @@ -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 () => { diff --git a/x-pack/plugins/alerting/server/rules_client/tests/mute_instance.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/mute_instance.test.ts index 1582b84e59da8..2d369bad2ce69 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/mute_instance.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/mute_instance.test.ts @@ -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', diff --git a/x-pack/plugins/alerting/server/rules_client/tests/run_soon.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/run_soon.test.ts index 99579a7831b94..080ed8cd44287 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/run_soon.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/run_soon.test.ts @@ -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 () => { diff --git a/x-pack/plugins/alerting/server/rules_client/tests/unmute_all.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/unmute_all.test.ts index 737974eeba11e..37f3b06f137a7 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/unmute_all.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/unmute_all.test.ts @@ -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 () => { diff --git a/x-pack/plugins/alerting/server/rules_client/tests/unmute_instance.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/unmute_instance.test.ts index 75f47210f11d1..f4f8d58f50e32 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/unmute_instance.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/unmute_instance.test.ts @@ -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', diff --git a/x-pack/plugins/alerting/server/rules_client/tests/update_api_key.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/update_api_key.test.ts index 8689e0e7de2ca..c915ccf1fe5c4 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/update_api_key.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/update_api_key.test.ts @@ -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', diff --git a/x-pack/test/alerting_api_integration/common/config.ts b/x-pack/test/alerting_api_integration/common/config.ts index 3e48a3b273019..69d84e39b10f2 100644 --- a/x-pack/test/alerting_api_integration/common/config.ts +++ b/x-pack/test/alerting_api_integration/common/config.ts @@ -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) { diff --git a/x-pack/test/alerting_api_integration/common/plugins/alerts/server/action_types.ts b/x-pack/test/alerting_api_integration/common/plugins/alerts/server/action_types.ts index cddf100205058..a7d5dbc138ea4 100644 --- a/x-pack/test/alerting_api_integration/common/plugins/alerts/server/action_types.ts +++ b/x-pack/test/alerting_api_integration/common/plugins/alerts/server/action_types.ts @@ -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; +} diff --git a/x-pack/test/alerting_api_integration/common/plugins/alerts/server/routes.ts b/x-pack/test/alerting_api_integration/common/plugins/alerts/server/routes.ts index 3ee46f4e885fe..f21894b93b6da 100644 --- a/x-pack/test/alerting_api_integration/common/plugins/alerts/server/routes.ts +++ b/x-pack/test/alerting_api_integration/common/plugins/alerts/server/routes.ts @@ -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, + res: KibanaResponseFactory + ): Promise> => { + 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 }); } } diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/bulk_enqueue.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/bulk_enqueue.ts new file mode 100644 index 0000000000000..589f31656736c --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/bulk_enqueue.ts @@ -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 => { + 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); + } + }; +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/enqueue.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/enqueue.ts new file mode 100644 index 0000000000000..b7266c2f66419 --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/enqueue.ts @@ -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 => { + 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()); + } + }; +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/execute.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/execute.ts index fffe1a70b27bb..8433570e08241 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/execute.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/execute.ts @@ -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 { - 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 validateSystemEventLog = async ( + params: Omit & { startDate: string } + ): Promise => { + const { spaceId, connectorId, outcome, message, startDate, errorMessage, source } = params; - // 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(); + 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 }]]), + }); - // expect(typeof duration).to.be('number'); - // expect(eventStart).to.be.ok(); - // expect(eventEnd).to.be.ok(); + const filteredEvents = events_.filter((event) => event!['@timestamp']! >= startDate); + if (filteredEvents.length < 1) throw new Error('no recent events found yet'); - // const durationDiff = Math.abs( - // Math.round(duration! / NANOS_IN_MILLIS) - (eventEnd - eventStart) - // ); + return filteredEvents; + }); - // // account for rounding errors - // expect(durationDiff < 1).to.equal(true); - // expect(eventStart <= eventEnd).to.equal(true); - // expect(eventEnd <= dateNow).to.equal(true); + expect(events.length).to.be(1); - // expect(event?.event?.outcome).to.equal(outcome); + const event = events[0]; - // expect(event?.kibana?.saved_objects).to.eql([ - // { - // rel: 'primary', - // type: 'action', - // id: connectorId, - // type_id: actionTypeId, - // namespace: spaceId, - // }, - // ]); + expect(event?.message).to.eql(message); + expect(event?.event?.outcome).to.eql(outcome); - // expect(event?.message).to.eql(message); + if (errorMessage) { + expect(event?.error?.message).to.eql(errorMessage); + } - // if (errorMessage) { - // expect(event?.error?.message).to.eql(errorMessage); - // } - } + if (source) { + expect(event?.kibana?.action?.execution?.source).to.eql(source.toLowerCase()); + } + }; } diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/get_all.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/get_all.ts index bc3444b5a32b3..744b1bde95216 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/get_all.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/get_all.ts @@ -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, diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/index.ts index 4f904f70b67f2..675f0c1a02bbc 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/index.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/index.ts @@ -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')); }); } diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/scenarios.ts b/x-pack/test/alerting_api_integration/security_and_spaces/scenarios.ts index 3e9137e58c5e7..a852657e0b891 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/scenarios.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/scenarios.ts @@ -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, diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/bulk_enqueue.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/bulk_enqueue.ts new file mode 100644 index 0000000000000..af5c91efcda11 --- /dev/null +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/bulk_enqueue.ts @@ -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 + ); + }); + }); +} diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/enqueue.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/enqueue.ts index 5934543886b16..238cfa3ae780b 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/enqueue.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/enqueue.ts @@ -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 + ); + }); }); } diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/execute.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/execute.ts index cb10066a50653..4acb40fcfe3f1 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/execute.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/execute.ts @@ -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 { @@ -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); diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get_all.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get_all.ts index 2366c392c4e4d..7f46a2ac625d6 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get_all.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get_all.ts @@ -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, diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/index.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/index.ts index e2675ca5a4e78..f1d7f59980c23 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/index.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/index.ts @@ -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'));