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:
- Adds
user_blocked_by_saml_required_groupanduser_blocked_by_scim_deprovisioningaudit event types, mirroringuser_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_groupsand 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+), viaGitlab::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.
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.rbManual 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)>