ADR-026: IdP-Neutral Identity Layer
On this page
Status
Accepted (2026-05-09). First implementation landed in Plan A Step 3
(Multi-Jurisdictional Authorization plan):
craig-security worker_identities table + lazy upsert middleware reading
craig_auth::Claims + GET /v1/security/workers?search= autocomplete. No IdP
admin API client; identity hydration is JWT-claims-only and eventually consistent.
Adopted by sibling project canopy as the reference worker-auth model (canopy ADR-019, 2026-05-08).
Revision 2026-05-13: the IdP-neutral claim now spans two validation
strategies — local JWS validation (the original path; Plan E-era) AND
RFC 7662 introspection. ADR-029
documents the introspection strategy; Plan F
shipped it across 6 step MRs (!269–!274). The neutrality claim reads:
"any OIDC backend with client_credentials, validated locally (JWS) or
via introspection (RFC 7662)." Kanidm 1.10.1 is now a supported backend
(was unviable on the JWS-only path because access tokens are JWE-
encrypted); ZITADEL’s default opaque-token mode is now supported;
Keycloak lightweight access tokens are now supported; authentik
encrypted-token mode is now supported. The "no IdP admin API client at
runtime" invariant is unchanged and enforced by the
no_admin_api_calls_per_adr_026 inline test in the Kanidm renderer.
Context
CRAIG must work with any deployer’s identity backend — Keycloak, Auth0, Okta, AD federation, SAML, etc. JWT validation (issuer + audience + expiry + JWKS signature check + typ) is OIDC-standard and works across IdPs without per-IdP code. But IdP admin APIs are NOT standardized — each vendor has its own user-management surface.
During Plan A’s design conversation, an early proposal called for a Keycloak admin API client to map preferred_username → sub UUID for backfilling data. The user pushed back: hardcoding Keycloak forces adopters to either fork or eat that choice. Same architectural anti-pattern as hardcoding a single jurisdiction’s authz policy.
For runtime use cases (e.g., reassignment-autocomplete UI: "I want to reassign this case to bob.smith"; the UI needs to translate username → sub) CRAIG needs a username-to-sub lookup. The IdP-neutral solution is to maintain CRAIG’s own table populated lazily from JWT claims.
Decision
-
New
worker_identitiestable in craig-security:(sub UUID PK, preferred_username TEXT, display_name, email, attrs JSONB, first_seen_at, last_seen_at). Trigram indexes for fuzzy autocomplete. (Initial schema declaredpreferred_usernameUNIQUE; dropped 2026-05-18 — uniqueness is the IdP’s contract, not CRAIG’s invariant, and the constraint produced upsert races when service-account UUIDs rotated. Seeservices/craig-security/migrations/20260518040000_drop_worker_identities_preferred_username_unique.sql.) -
crates/craig-authmiddleware upsertsworker_identitieson every authenticated request (cheap; indexed PK upsert). -
New endpoint
GET /v1/security/workers?search=&limit=for BFF reassignment autocomplete. Rate-limited; minimum 2-char query. -
attrsJSONB column captures non-standard JWT claims (e.g.,assigned_unitsfrom Keycloak custom mappers, Auth0 metadata, Okta profile attributes). Per-deployment IdP claim mapping is documented in jurisdiction-onboarding playbook. -
CRAIG never calls an IdP admin API.
-
For one-time bootstrap data imports (legacy CCWIS migration), the implementing jurisdiction’s data-import tool handles the username→sub mapping outside core CRAIG.
Consequences
-
Identity backend remains pluggable across Keycloak/Auth0/Okta/AD/SAML
-
No per-IdP code paths in CRAIG
-
worker_identitiesaccumulates entries lazily as users log in; first-login bootstrap covers the common case -
Custom claims drive per-jurisdiction authz expressions without CRAIG-side changes (e.g., a tribal-coordinator role with admin_unit-scoped scope reads
claims.attrs.assigned_units)
Amendment — #1155 write-on-read debounce (2026-07-26)
The Decision’s "upserts worker_identities on every authenticated request (cheap; indexed PK
upsert)" aged into a measurable write-on-read cost (external perf-audit finding F19, epic &73):
every authenticated craig-security request took the same row’s lock plus one WAL record, via an
unbounded detached task, and the partner API-key / signer-key hot-path lookups detached
last_used_at UPDATEs the same way. These stamps are display/telemetry freshness, not
correctness data, so bounded staleness is the right trade.
As built, the middleware (services/craig-security/src/api/worker_identity_middleware.rs —
craig-security’s, running after craig-auth’s auth_middleware) and both hot-path lookups now
debounce through one shared process-local gate (services/craig-security/src/touch_gate.rs):
at most one touch per key per 60-second window per process, and at most 32 in-flight
touch tasks (saturation sheds the touch, never queues it). Recorded consequences:
-
last_seen_at/last_used_atand display-field refreshes (a renamed principal) lag by at most one window. -
Multi-replica deployments write once per window per replica — still bounded.
-
A failed touch is not retried until the window elapses (the pre-existing best-effort contract: a missed write means the principal shows up within a window).
-
The lazy-populate contract is otherwise unchanged — a first-seen principal writes immediately, except when shed at the concurrency ceiling (previous bullet), in which case its first write lands after the window like any other deferred touch.