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_identities table 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 declared preferred_username UNIQUE; 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. See services/craig-security/migrations/20260518040000_drop_worker_identities_preferred_username_unique.sql.)

  • crates/craig-auth middleware upserts worker_identities on 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.

  • attrs JSONB column captures non-standard JWT claims (e.g., assigned_units from 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_identities accumulates 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_at and 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.

  • ADR-023 — Authz architecture

  • ADR-025 — Identity normalization

  • ADR-027 — Architectural principles (pluggability, configuration-as-data)

Edit this page · latest