From 91b764cf9f93a4ea455036a650476cd933096a29 Mon Sep 17 00:00:00 2001 From: Alexi Doak <109488926+doakalexi@users.noreply.github.com> Date: Thu, 19 Dec 2024 10:30:15 -0800 Subject: [PATCH] [ResponseOps] Granular Connector RBAC - adding API key to event log (#204114) Part of https://github.com/elastic/kibana/issues/180908 ## Summary This change is part of adding granular RBAC for SecuritySolution connectors. In this PR, I updated the action executor to log API key details when a connector is executed by a user authenticated via API key. The public name and id of the API key are now included in the event log. ### Checklist Check the PR satisfies following conditions. - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios ### To verify 1. Create an API key 2. Create a connector that will successfully run, it doesn't have to be SentinelOne. 3. Run the following with the ID and correct params for your connector type. ``` curl -X POST "http://localhost:5601/api/actions/connector/$CONNECTOR_ID/_execute" -H 'Authorization: ApiKey $API_KEY' -H 'kbn-xsrf: true' -H 'Content-Type: application/json' -d' { "params": { "message": "hi" } }' ``` 4. Go to dev tools and run the following query to verify that the API key information is stored in the event log ``` GET /.kibana-event-log*/_search { "sort": [ { "@timestamp": { "order": "desc" } } ], "query": { "bool": { "filter": [ { "term": { "event.provider": { "value": "actions" } } } ] } } ``` --- .../src/authentication/authenticated_user.ts | 20 ++++ .../server/lib/action_executor.test.ts | 91 ++++++++++++++++--- .../actions/server/lib/action_executor.ts | 1 + .../plugins/event_log/generated/mappings.json | 10 ++ x-pack/plugins/event_log/generated/schemas.ts | 6 ++ x-pack/plugins/event_log/scripts/mappings.js | 10 ++ .../group2/tests/actions/execute.ts | 78 ++++++++++++++++ 7 files changed, 204 insertions(+), 12 deletions(-) diff --git a/packages/core/security/core-security-common/src/authentication/authenticated_user.ts b/packages/core/security/core-security-common/src/authentication/authenticated_user.ts index d80ff8f434a4f..f550707290de7 100644 --- a/packages/core/security/core-security-common/src/authentication/authenticated_user.ts +++ b/packages/core/security/core-security-common/src/authentication/authenticated_user.ts @@ -25,6 +25,21 @@ export interface UserRealm { type: string; } +/** + * Represents the metadata of an API key. + */ +export interface ApiKeyDescriptor { + /** + * Name of the API key. + */ + name: string; + + /** + * The ID of the API key. + */ + id: string; +} + /** * Represents the currently authenticated user. */ @@ -65,4 +80,9 @@ export interface AuthenticatedUser extends User { * Indicates whether user is an operator. */ operator?: boolean; + + /** + * Metadata of the API key that was used to authenticate the user. + */ + api_key?: ApiKeyDescriptor; } 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 76354dc882dd9..b89b997ca749d 100644 --- a/x-pack/plugins/actions/server/lib/action_executor.test.ts +++ b/x-pack/plugins/actions/server/lib/action_executor.test.ts @@ -229,6 +229,18 @@ const getBaseExecuteEventLogDoc = ( }; const mockGetRequestBodyByte = jest.spyOn(ConnectorUsageCollector.prototype, 'getRequestBodyByte'); +const mockRealm = { name: 'default_native', type: 'native' }; +const mockUser = { + authentication_realm: mockRealm, + authentication_provider: mockRealm, + authentication_type: 'realm', + lookup_realm: mockRealm, + elastic_cloud_user: true, + enabled: true, + profile_uid: '123', + roles: ['superuser'], + username: 'coolguy', +}; beforeEach(() => { jest.resetAllMocks(); @@ -236,18 +248,7 @@ beforeEach(() => { mockGetRequestBodyByte.mockReturnValue(0); spacesMock.getSpaceId.mockReturnValue('some-namespace'); loggerMock.get.mockImplementation(() => loggerMock); - const mockRealm = { name: 'default_native', type: 'native' }; - securityMockStart.authc.getCurrentUser.mockImplementation(() => ({ - authentication_realm: mockRealm, - authentication_provider: mockRealm, - authentication_type: 'realm', - lookup_realm: mockRealm, - elastic_cloud_user: true, - enabled: true, - profile_uid: '123', - roles: ['superuser'], - username: 'coolguy', - })); + securityMockStart.authc.getCurrentUser.mockImplementation(() => mockUser); getActionsAuthorizationWithRequest.mockReturnValue(authorizationMock); }); @@ -1563,6 +1564,72 @@ describe('Event log', () => { message: 'action started: test:1: action-1', }); }); + + test('writes to the api key to the event log', async () => { + securityMockStart.authc.getCurrentUser.mockImplementationOnce(() => ({ + ...mockUser, + authentication_type: 'api_key', + api_key: { + id: '456', + name: 'test api key', + }, + })); + + const executorMock = setupActionExecutorMock(); + executorMock.mockResolvedValue({ + actionId: '1', + status: 'ok', + }); + await actionExecutor.execute(executeParams); + expect(eventLogger.logEvent).toHaveBeenCalledTimes(2); + expect(eventLogger.logEvent).toHaveBeenNthCalledWith(2, { + event: { + action: 'execute', + kind: 'action', + outcome: 'success', + }, + kibana: { + action: { + execution: { + usage: { + request_body_bytes: 0, + }, + uuid: '2', + }, + id: '1', + name: 'action-1', + type_id: 'test', + }, + alert: { + rule: { + execution: { + uuid: '123abc', + }, + }, + }, + user_api_key: { + id: '456', + name: 'test api key', + }, + saved_objects: [ + { + id: '1', + namespace: 'some-namespace', + rel: 'primary', + type: 'action', + type_id: 'test', + }, + ], + space_ids: ['some-namespace'], + }, + message: 'action executed: test:1: action-1', + user: { + id: '123', + name: 'coolguy', + }, + }); + }); + const mockGenAi = { id: 'chatcmpl-7LztF5xsJl2z5jcNpJKvaPm4uWt8x', object: 'chat.completion', diff --git a/x-pack/plugins/actions/server/lib/action_executor.ts b/x-pack/plugins/actions/server/lib/action_executor.ts index 799fdb80f39af..b0d8e7c5b469c 100644 --- a/x-pack/plugins/actions/server/lib/action_executor.ts +++ b/x-pack/plugins/actions/server/lib/action_executor.ts @@ -552,6 +552,7 @@ export class ActionExecutor { event.user = event.user || {}; event.user.name = currentUser?.username; event.user.id = currentUser?.profile_uid; + event.kibana!.user_api_key = currentUser?.api_key; set( event, 'kibana.action.execution.usage.request_body_bytes', diff --git a/x-pack/plugins/event_log/generated/mappings.json b/x-pack/plugins/event_log/generated/mappings.json index 5fc8128baa7ae..110cc3b6665f9 100644 --- a/x-pack/plugins/event_log/generated/mappings.json +++ b/x-pack/plugins/event_log/generated/mappings.json @@ -523,6 +523,16 @@ } } } + }, + "user_api_key": { + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + } + } } } } diff --git a/x-pack/plugins/event_log/generated/schemas.ts b/x-pack/plugins/event_log/generated/schemas.ts index 7542d6db5213a..ef3e9c7facbf9 100644 --- a/x-pack/plugins/event_log/generated/schemas.ts +++ b/x-pack/plugins/event_log/generated/schemas.ts @@ -237,6 +237,12 @@ export const EventSchema = schema.maybe( ), }) ), + user_api_key: schema.maybe( + schema.object({ + id: ecsString(), + name: ecsString(), + }) + ), }) ), }) diff --git a/x-pack/plugins/event_log/scripts/mappings.js b/x-pack/plugins/event_log/scripts/mappings.js index 770f9e6d45f9a..349ed4903ae29 100644 --- a/x-pack/plugins/event_log/scripts/mappings.js +++ b/x-pack/plugins/event_log/scripts/mappings.js @@ -299,6 +299,16 @@ exports.EcsCustomPropertyMappings = { }, }, }, + user_api_key: { + properties: { + id: { + type: 'keyword', + }, + name: { + type: 'keyword', + }, + }, + }, }, }, }; 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 ddd2ed954efe7..4d5204b067643 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 @@ -573,6 +573,84 @@ export default function ({ getService }: FtrProviderContext) { throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); } }); + + it('should log api key information from execute request', async () => { + const { body: createdApiKey } = await supertest + .post(`/internal/security/api_key`) + .set('kbn-xsrf', 'foo') + .send({ name: 'test user managed key' }) + .expect(200); + const apiKey = createdApiKey.encoded; + + const connectorTypeId = 'test.index-record'; + const { body: createdConnector } = await supertest + .post(`${getUrlPrefix(space.id)}/api/actions/connector`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'My Connector', + connector_type_id: connectorTypeId, + config: { + unencrypted: `This value shouldn't get encrypted`, + }, + secrets: { + encrypted: 'This value should be encrypted', + }, + }) + .expect(200); + objectRemover.add(space.id, createdConnector.id, 'connector', 'actions'); + + const reference = `actions-execute-1:${user.username}`; + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(space.id)}/api/actions/connector/${createdConnector.id}/_execute`) + .set('kbn-xsrf', 'foo') + .set('Authorization', `ApiKey ${apiKey}`) + .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': + case 'global_read at space1': + 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( + 'action:test.index-record', + reference + ); + // @ts-expect-error doesnt handle total: number + expect(searchResult.body.hits.total.value > 0).to.be(true); + + const events: IValidatedEvent[] = await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: space.id, + type: 'action', + id: createdConnector.id, + provider: 'actions', + actions: new Map([ + ['execute-start', { equal: 1 }], + ['execute', { equal: 1 }], + ]), + }); + }); + const executeEvent = events[1]; + expect(executeEvent?.kibana?.user_api_key?.id).to.eql(createdApiKey.id); + expect(executeEvent?.kibana?.user_api_key?.name).to.eql(createdApiKey.name); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); }); } });