Show block reason badge on admin users page
What does this MR do and why?
The ldap_blocked user state can be triggered by three unrelated features: LDAP sync, SAML required_groups, and SCIM deprovisioning. Today, the Admin Users page shows all three under a generic "LDAP Blocked" badge, giving admins no reliable way to distinguish which mechanism actually blocked the user.
This MR updates the badge (EE::UsersHelper#blocked_user_badge) to look up the most recent relevant UserAuditEvent for a blocked user and display the actual reason: "LDAP Blocked", "SAML Blocked", or "SCIM Blocked". When no matching event exists (unlicensed instances or blocks predating this feature), it falls back to the existing "LDAP Blocked" text.
This is a split of !248851 (merged) per @habdul-razak's review suggestion: the audit event types stay in that MR, while this badge half — a new database query pattern — gets its own focused review.
This MR is EE-only and meaningful only for Premium+ instances (SAML required_groups and SCIM deprovisioning don't exist in CE; audit event creation itself is gated behind admin_audit_log and extended_audit_events, both Premium+).
The MR functionally depends on the audit event types added by !248851 (merged) but is self-contained code and test-wise — the badge works correctly today without that MR; it simply becomes more useful once those events start being logged.
Disclosure
The root-cause analysis, design decisions, 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.
Database review
event_name is a real, plain text column on user_audit_events (populated by AuditEvents::BuildService#base_payload alongside the serialized details blob) — not buried inside details, so it's filtered directly in SQL via a new by_event_name scope, combined with the existing by_user scope. No window function, no per-user event cap needed: filtering to only the 3 badge-relevant event names up front means there's no bounded window to size correctly in the first place. Request-level caching via Gitlab::SafeRequestStore still prevents repeated lookups if the same collection is rendered multiple times in a single request, and a BatchLoader::GraphQL batches the lookup across a GraphQL page for the one caller (organizationUsers) that doesn't preload ahead of time.
Query (AuditEvents::UserAuditEvent.by_user(user_ids).by_event_name(event_names).order_by('created_desc', use_created_at: true)), captured on a local GDK against the partitioned user_audit_events table:
SELECT "user_audit_events".* FROM "user_audit_events"
WHERE "user_audit_events"."user_id" = 1
AND "user_audit_events"."event_name" IN ('user_blocked_by_ldap_sync', 'user_blocked_by_saml_required_group', 'user_blocked_by_scim_deprovisioning')
ORDER BY "user_audit_events"."created_at" DESC, "user_audit_events"."id" DESCEXPLAIN (ANALYZE, BUFFERS):
Append (cost=1.74..26.58 rows=11 width=284) (actual time=0.185..0.947 rows=1 loops=1)
Buffers: shared hit=23 read=4
-> Index Scan Backward using user_audit_events_202703_user_id_created_at_id_idx on user_audit_events_202703 (cost=0.15..2.17 rows=1 width=264) (actual time=0.067..0.067 rows=0 loops=1)
Index Cond: (user_id = 1)
Filter: (event_name = ANY ('{user_blocked_by_ldap_sync,user_blocked_by_saml_required_group,user_blocked_by_scim_deprovisioning}'::text[]))
Buffers: shared hit=5
-> [... one Index Scan Backward per monthly partition, same shape, on user_audit_events_202702 / 202701 / 202612 / 202611 / 202610 / 202609 / 202608 / 202607 / 202606 / 000000 ...]
Planning:
Buffers: shared hit=1573 read=1
Planning Time: 14.582 ms
Execution Time: 1.144 msEvery partition hits the existing (user_id, created_at, id) btree index (idx_user_audit_events_on_project_created_at_id) via user_id — event_name isn't itself indexed, but is applied as a cheap post-index filter over the already-tiny per-user, per-partition row set. No sequential scans anywhere. 1.1ms total execution time.
Screenshots or screen recordings
Before (generic reason shown): https://gitlab.com/-/project/41372369/uploads/c5d6f915be091c10a1328bd5fec68195/badge_before.png
After (correct reason shown): https://gitlab.com/-/project/41372369/uploads/747bc22c57814d1b19562771f606de3d/badge_after.png
How to set up and validate locally
Run the test suite:
bin/rspec ee/spec/helpers/users_helper_spec.rb