Add audit events when a user is blocked by SAML required-group or SCIM deprovisioning

What does this MR do and why?

The ldap_blocked user state is shared by three unrelated producers: LDAP sync, SAML required_groups, and SCIM deprovisioning (see the comment on User#system_block and the older #388487). Admins have no reliable way to tell them apart today — the Admin UI's "LDAP Blocked" badge is identical regardless of which one actually blocked the user, and the only trace of the real reason is an AppLogger line, which isn't queryable and isn't guaranteed to be retained.

This MR:

  1. Adds user_blocked_by_saml_required_group and user_blocked_by_scim_deprovisioning audit event types, mirroring user_blocked_by_ldap_sync (added in !247146 (merged)). Both are guarded on the underlying state-machine transition actually succeeding, so repeated blocked sign-in/deprovisioning attempts don't create duplicate events.

Closes #578102

Why audit events, not a state rename

The older #388487 asks to rename ldap_blocked itself to something generic like system_blocked. That's a much larger, more invasive change — it touches every LDAP/SAML/SCIM call site plus anything reading the raw state string, which is presumably why it's sat open since 2023. This MR reads #388487's actual complaint (admins can't tell why a user is blocked) as solvable without the rename, by making the displayed reason accurate instead. Left a comment on #388487 explaining this framing; not closing it, since the broader rename ask still stands on its own if anyone wants it.

Scope

This is EE-only, and only meaningfully helps Premium+ instances:

  • SAML required_groups and SCIM deprovisioning don't exist in CE at all.
  • LDAP itself is CE, but the audit-event trail for it only fires via the EE override; CE's own hook is a no-op.
  • Audit event creation itself is gated behind admin_audit_log/extended_audit_events (both Premium+), via Gitlab::Audit::Auditor#audit_enabled?. Unlicensed instances get no write at all (not just a hidden one), so the badge correctly falls back to the generic text there.

Disclosure

The root-cause analysis, design decisions (audit-event trail over state rename, the SQL/Ruby filtering split, the transition-guard to avoid duplicate events), and implementation were done with AI assistance (Claude), verified via the test suite (TDD throughout — each new behavior was confirmed red before implementing) and by reading the actual GitLab source referenced above rather than assuming it.

Screenshots or screen recordings

This MR writes audit events only. The screenshot shows the result when combined with !251423, which displays these events as a badge in the admin UI.

image

How to set up and validate locally

bin/rspec ee/spec/lib/gitlab/auth/saml/user_spec.rb
bin/rspec ee/spec/lib/gitlab/scim/deprovisioning_service_spec.rb

Manual validation (2026-08-18)

Verified live against this GDK, not just via specs:

~/gdk/gitlab$ git status
On branch 578102-correct-block-reason-badge
nothing to commit, working tree clean
~/gdk/gitlab$ git log -n 1
commit e91f1fb543691f4d1eaa9ae5c75774334ebfbda6 (HEAD -> 578102-correct-block-reason-badge)
Author: Sergey Pechenko <9500609-tnt4brain@users.noreply.gitlab.com>
Date:   Sat Aug 22 04:42:30 2026 +0200

    Remove unnecessary comment and compact from audit event logging

    Address review feedback on
    https://gitlab.com/gitlab-org/gitlab/-/merge_requests/248851: removed
    the explanatory comment above log_audit_event_for_block and .compact
    calls from additional_details hashes. Applied to both SAML and SCIM
    audit event logging, since none of the hash values can actually be
    nil.
~/gdk/gitlab$ rails console
--------------------------------------------------------------------------------
 Ruby:         ruby 3.3.11 (2026-03-26 revision 1f2d15125a) [x86_64-linux]
 GitLab:       19.3.0-pre (e91f1fb5436) EE
 GitLab Shell: 14.56.1
 PostgreSQL:   17.8
------------------------------------------------------------[ booted in 23.61s ]
Loading development environment (Rails 7.2.3.1)
[1] pry(main)> res = Users::CreateService.new(
  nil, confirmation: true,
  username: 'note_3729855575',
  email: 'test@example.com',
  name: 'Test User',
  password: 'SecurePasswordMaybe123',
  password_confirmation: 'SecurePasswordMaybe123',
  organization_id: Organizations::Organization.first.id,
  skip_confirmation: true).execute
# lots of DB-related logs here
=> #<ServiceResponse:0x00007f072c961060 @http_status=:ok, @message=nil, @payload={:user=>#<User id:50 @note_3729855575>}, @reason=nil, @status=:success>
[2] pry(main)> evt = Gitlab::Audit::Auditor.audit(
  name: 'user_blocked_by_saml_required_group',
  message: 'Blocked user by SAML required group',
  author: Gitlab::Audit::UnauthenticatedAuthor.new(name: '(System)'),
  scope: res.payload[:user], target: res.payload[:user], target_details: res.payload[:user].username,
  additional_details: { system_event: true, reason: 'not in required group', provider: 'saml', extern_uid: 'uid123' })
# lots of DB-related logs here
=> [#<AuditEvent:0x00007fd108adc240
  id: nil,
  author_id: -1,
  entity_id: nil,
  entity_type: "User",
  details:
   {:system_event=>true, :reason=>"not in required group", :provider=>"saml", :extern_uid=>"uid123", :event_name=>"user_blocked_by_saml_required_group", :author_name=>"(System)", :author_class=>"Gitlab::Audit::UnauthenticatedAuthor", :target_id=>nil, :target_type=>"User", :target_details=>"note_3729855575", :custom_message=>"Blocked user by SAML required group", :ip_address=>nil, :entity_path=>"note_3729855575"},
  ip_address: nil,
  author_name: "(System)",
  entity_path: nil,
  target_details: nil,
  created_at: "2026-08-27 00:34:53.108591075 +0000",
  target_type: nil,
  target_id: nil>]
[3] pry(main)>
`audit_json.log` shows the structured entry with `reason`/`provider`/`extern_uid` in `additional_details` {"severity":"INFO","time":"2026-08-27T00:34:53.278Z","id":null,"author_id":-1,"entity_id":null,"entity_type":"User","details":{"system_event":true,"reason":"not in required group","provider":"saml","extern_uid":"uid123","event_name":"user_blocked_by_saml_required_group","author_name":"(System)","author_class":"Gitlab::Audit::UnauthenticatedAuthor","target_id":null,"target_type":"User","target_details":"note_3729855575","custom_message":"Blocked user by SAML required group","ip_address":null,"entity_path":"note_3729855575"},"ip_address":null,"author_name":"(System)","entity_path":"note_3729855575","target_details":"note_3729855575","created_at":"2026-08-27T00:34:53.108Z","target_type":"User","target_id":null,"system_event":true,"reason":"not in required group","provider":"saml","extern_uid":"uid123","event_name":"user_blocked_by_saml_required_group","author_class":"Gitlab::Audit::UnauthenticatedAuthor","custom_message":"Blocked user by SAML required group"}
Edited by Sergey Pechenko

Merge request reports

Loading
Loading