Add audit event for service account creation

Summary

When a service account is created (via API or UI at the instance, group, or project level), no dedicated audit event is recorded. This is a gap in audit coverage, as service accounts have elevated access and their creation should be tracked for compliance and security purposes.

There is a generic user_created audit event that fires for regular user creation (via the EE override in ee/app/services/ee/users/create_service.rb), but it provides no service-account-specific context. A dedicated event is needed for security monitoring and SIEM integration.

A related issue exists for DAP (Duo Agent Platform) auto-provisioned service accounts (#593021), but this issue covers all service account creation paths.

Proposal

Add a new service_account_created audit event that fires whenever a service account is successfully created, regardless of the entry point.

Class hierarchy and entry points

The service account creation class hierarchy is:

Users::ServiceAccounts::CreateService < BaseService
├── Namespaces::ServiceAccounts::CreateService      (used for group/project-level creation)
└── Namespaces::ServiceAccounts::BaseCreateService  (sibling class, appears unused in production code)

Important: BaseCreateService and CreateService are siblings, not parent-child. Both inherit independently from Users::ServiceAccounts::CreateService. BaseCreateService has no references in production code (only its own spec references it) and may be dead code or reserved for future use. It is not in the current call chain for service account creation.

The active creation paths are:

  1. Users::ServiceAccounts::CreateService (ee/app/services/users/service_accounts/create_service.rb) — Instance-level service account creation. Delegates to ::Users::CreateService.
  2. Namespaces::ServiceAccounts::CreateService (ee/app/services/namespaces/service_accounts/create_service.rb) — Group/project-level service account creation. Overrides create_user to delegate to ::Users::AuthorizedCreateService.

Since both share the execute method from Users::ServiceAccounts::CreateService, adding the audit call there covers all active paths.

Duplicate audit events — intentional and expected

The EE override at ee/app/services/ee/users/create_service.rb already fires a user_created audit event in after_create_hook whenever current_user is present. Since Users::ServiceAccounts::CreateService#create_user delegates to ::Users::CreateService, every successful service account creation will produce both:

  • user_created (from the inner Users::CreateService via the EE prepend chain)
  • service_account_created (from the new code)

This duplication is intentional and acceptable. The two events serve different purposes:

  • user_created is a generic user lifecycle event
  • service_account_created is a security-specific event that enables SIEM rules to distinguish service account creation from regular user creation, and includes the appropriate scope (group/project) rather than just the user

This should be explicitly documented in the MR description so reviewers don't flag it as a bug.

Implementation steps

1. Create the YAML audit event type definition

Create a new file at ee/config/audit_events/types/service_account_created.yml:

---
name: service_account_created
description: A service account is created
introduced_by_issue: <this issue URL>
introduced_by_mr: <MR URL>
feature_category: system_access
milestone: '<current milestone>'
saved_to_database: true
streamed: true
scope: [User, Group, Project]

Note on scope values: The valid values per config/audit_events/types/type_schema.json are Instance, Group, Project, and User. The existing user_created.yml uses scope: [User] (not Instance) for instance-level user creation. For consistency, use User for the instance-level scope here as well, alongside Group and Project for namespace-scoped creation.

You can also use the CLI helper to generate the file:

bin/audit-event-type service_account_created

The YAML schema is defined in config/audit_events/types/type_schema.json. All required fields are: name, description, introduced_by_issue, introduced_by_mr, feature_category, milestone, saved_to_database, streamed, scope.

2. Add the Gitlab::Audit::Auditor.audit call

Updating the execute method

The current execute method in Users::ServiceAccounts::CreateService returns the result of create_user directly:

# Current implementation
def execute
  return error(error_messages[:no_permission], :forbidden) unless can_create_service_account?
  return error(error_messages[:no_seats], :forbidden) unless creation_allowed?
  create_user
end

create_user delegates to ::Users::CreateService#execute, which returns a ServiceResponse with status (:success or :error) and payload (containing { user: <User> }). The method must be updated to capture the result, audit on success, and return:

# Updated implementation
def execute
  return error(error_messages[:no_permission], :forbidden) unless can_create_service_account?
  return error(error_messages[:no_seats], :forbidden) unless creation_allowed?

  result = create_user
  log_service_account_audit_event(result.payload[:user]) if result.success?
  result
end

The audit method

# In Users::ServiceAccounts::CreateService (base class)
def audit_scope(user)
  user  # instance-level, same pattern as user_created
end

def log_service_account_audit_event(user)
  ::Gitlab::Audit::Auditor.audit({
    name: "service_account_created",
    author: current_user || ::Gitlab::Audit::UnauthenticatedAuthor.new(name: '(System)'),
    scope: audit_scope(user),
    target: user,
    target_details: user.username,
    message: "Created service account #{user.username}"
  })
end

Scope override in the subclass

Namespaces::ServiceAccounts::CreateService already has extend ::Gitlab::Utils::Override, so the override can be added directly:

# In Namespaces::ServiceAccounts::CreateService
# (extend ::Gitlab::Utils::Override is already present in this class)
override :audit_scope
def audit_scope(_user)
  resource  # the Group or Project
end

Note: Users::ServiceAccounts::CreateService (the base class) does not currently have extend ::Gitlab::Utils::Override. If you want to use override annotations in the base class for any reason, you would need to add it. However, since audit_scope is defined (not overridden) in the base class, no override declaration is needed there.

Author handling for system-initiated flows

The implementation uses defensive author handling:

author: current_user || ::Gitlab::Audit::UnauthenticatedAuthor.new(name: '(System)')

Even though current manual creation paths always have a current_user, the audit code lives in the base class which could be called from any path in the future (including DAP auto-provisioning covered by #593021).

3. Update documentation

After adding the audit event type, regenerate the audit event types documentation:

bundle exec rake gitlab:audit_event_types:compile_docs

Verify it is up-to-date:

bundle exec rake gitlab:audit_event_types:check_docs

4. Write tests

Follow the existing test pattern from ee/spec/services/ee/users/create_service_spec.rb:

context 'audit events' do
  it 'logs the audit event info' do
    expect(::Gitlab::Audit::Auditor).to receive(:audit).with(hash_including(
      name: 'service_account_created'
    ))

    service.execute
  end
end

Required test cases

  • Instance-level creation (Users::ServiceAccounts::CreateService) — verify event fires with User scope
  • Group-level creation (Namespaces::ServiceAccounts::CreateService with namespace_id) — verify event fires with Group scope
  • Project-level creation (Namespaces::ServiceAccounts::CreateService with project_id) — verify event fires with Project scope
  • Failure scenarios — verify the audit event is not created when creation fails (permission denied, no seats)
  • Scope correctness — verify the scope is the correct object for each level (not just that the event fires)
  • target_details — verify it contains the expected username
  • Dual event verification — verify that both user_created and service_account_created fire on successful creation (documenting the intentional duplication)
  • skip_owner_check: true path — verify audit still fires when using the composite identity flow
  • AuditEvent record verification — in addition to mocking Auditor.audit, verify the actual AuditEvent record attributes (as ee/spec/services/ee/users/create_service_spec.rb does with AuditEvent.last)

Developer guidelines reference

The full audit event development guide is at doc/development/audit_event_guide/_index.md. Key points:

  • Use Gitlab::Audit::Auditor.audit (the standard method call for single events)
  • Do not translate audit event messages (they are stored in the database and served regardless of locale)
  • Consider data volume: service account creation is a low-frequency operation, so saved_to_database: true is appropriate
  • All events where the entity is a Group or Project are automatically streamed to configured event streaming destinations

Existing similar audit events for reference

Event name File Scope Description
user_created ee/config/audit_events/types/user_created.yml [User] A user is created
group_access_token_created ee/config/audit_events/types/group_access_token_created.yml [Group] A group access token is created
project_access_token_created ee/config/audit_events/types/project_access_token_created.yml [Project] A project access token is created

Effort estimate

Low — This is a small, well-patterned change. The main work is writing the specs.

Edited by 🤖 GitLab Bot 🤖