Record human author on AI audit events under composite identity

What does this MR do and why?

AI audit events (ai_audit_events) ingested from Duo Workflow Service lose the composite identity. The ingest endpoint authenticates with the flow's service-account OAuth token, and auth_finders resolves current_user for such tokens to the scoped human user (Gitlab::Auth::AuthFinders#resolve_composite_identity_user). So the events were attributed to the human, with no trace of the service account that actually authenticated and nothing linking the two - the same defect !240418 (merged) fixes for standard audit events.

This applies the same convention to the AI ingest path:

  • Resolve the author with Gitlab::Auth::Identity.resolve_composite_identity_actor, exactly like Gitlab::Audit::Auditor does, so author_id points at the service account, the true authenticating actor.
  • Record the triggering human as flat human_author_id / human_author_name / human_author_username keys in the event details JSON, the same keys the Auditor writes, so streaming consumers see one consistent shape. The keys are merged into details last, so a relayed payload cannot spoof them.
  • Outside composite identity (plain service account, human's own token, permission_check-context links) behavior is unchanged: author_id stays on current_user and no human keys are written.

No code dependency on !240418 (merged). The human_author_* key names, however, deliberately mirror the details convention !240418 (merged) introduces (still in review) - if that MR renames the keys, this path must follow. The CompositeIdentityAuthor wrapper is not needed here: it feeds the persisted author_name column, which ai_audit_events doesn't have (author_name derives from author_id at serialization time).

References

  • Closes #604323
  • #599098 - service account attribution on standard audit events (same convention)
  • !240418 (merged) - introduces the human_author_* details convention this MR reuses

Screenshots or screen recordings

Before After

Screenshot 2026-07-30 at 15.22.08.png

Event in the list is attributed to a human

Screenshot 2026-07-30 at 15.24.14.png

Author name - attributed to a human, no human author details shown

Screenshot 2026-07-30 at 15.22.20.png

Event in the list is attributed to a service account

Screenshot 2026-07-30 at 15.25.49.png

Author name - attributed to the service account, additional human author details are shown

Note, no frontend code changes are in this MR, what we see is the consequence of placing different values to existing attributes.

There might be a potential minor UX issue with the change because the user is no longer able to click onto human name to see their profile, and requires manual search, I suggest to iterate outside of the scope of the current MR - Relevant thread

How to set up and validate locally

The steps exercise the real request path: a composite service-account OAuth token sent to the ingest endpoint over HTTP, so the auth resolution in auth_finders runs for real instead of being simulated in a console. Verified on GDK, each step below was run as written.

  1. In a rails console, prepare a composite service account, a workflow and a token. This mirrors what the product does when starting a flow:

    admin = User.find_by!(username: 'root')
    org = Organizations::Organization.default_organization
    
    Ai::DuoWorkflows::OnboardingService.new(current_user: admin, organization: org).execute
    sa = User.find(Ai::Setting.instance.duo_workflow_service_account_user_id)
    
    project = Project.find_by_full_path('flightjs/Flight') # any project works
    project.project_setting.update!(duo_features_enabled: true, ai_audit_events_storage_enabled: true)
    project.add_developer(sa)
    
    workflow = Ai::DuoWorkflows::Workflow.create!(
      user: admin, project: project, goal: 'verify attribution',
      workflow_definition: 'software_development', environment: :web, service_account: sa)
    
    token = Ai::DuoWorkflows::CreateCompositeOauthAccessTokenService.new(
      current_user: admin, organization: org, service_account: sa, container: project
    ).execute.payload[:oauth_access_token]
    puts token.plaintext_token, workflow.id

    Without duo_features_enabled and the SA membership the endpoint returns 403 (read_duo_workflow requires the ability for both the human and the SA). Without ai_audit_events_storage_enabled the worker silently drops the events.

  2. Optional sanity check, and the premise of this MR in one request: the API resolves this token to the human, not to the service account that owns it:

    curl -s -H "Authorization: Bearer <TOKEN>" http://gdk.test:3000/api/v4/user
    # => {"id":1,"username":"root",...}
  3. Send an event to the ingest endpoint:

    curl -X POST -H "Authorization: Bearer <TOKEN>" -H "Content-Type: application/json" \
      -d '{"events":[{"id":"'$(uuidgen | tr 'A-Z' 'a-z')'","type":"ai_llm_input_sent","source":"duo-workflow-service","time":"'$(date -u +%FT%TZ)'","data":{"check":"composite"}}]}' \
      http://gdk.test:3000/api/v4/ai/duo_workflows/workflows/<WORKFLOW_ID>/audit_events
  4. Check the stored event (sidekiq picks the job up with some delay on GDK, retry if empty):

    event = AuditEvents::AiAuditEvent.for_workflow(workflow.id).recent_first.first
    event.author_id # => sa.id (the service account)
    event.details.with_indifferent_access.slice(:human_author_id, :human_author_name, :human_author_username)
    # => {"human_author_id"=>1, "human_author_name"=>"Administrator", "human_author_username"=>"root"}
  5. Negative path, no composite identity: mint a plain token owned by the human (the legacy Duo Workflow token path) and repeat the ingest request with it:

    token = Ai::DuoWorkflows::CreateOauthAccessTokenService.new(
      current_user: admin, organization: org
    ).execute.payload[:oauth_access_token]

    The stored event keeps author_id on the human and its details contain only the payload keys plus ip_address, no human_author_* keys.

  6. Optional, to see the events in the UI: enable the page flag and sync the session artifact (console-created workflows never populate duo_workflow_session_artifacts):

    Feature.enable(:agent_artifacts_page)
    Ai::DuoWorkflows::SessionArtifact.sync_from_workflow!(workflow)

    Then open <project>/-/security/agent_artifacts directly (the page has no menu entry yet), click the session and open an event: the author is the service account and the Details section lists the human author fields.

MR acceptance checklist

Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.

Edited by Vasyl Pedak

Merge request reports

Loading