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" DESC

EXPLAIN (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 ms

Every partition hits the existing (user_id, created_at, id) btree index (idx_user_audit_events_on_project_created_at_id) via user_idevent_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
Edited by Sergey Pechenko

Merge request reports

Loading
Loading