Plan E: craig-identity Contract + Service Identity + On-Behalf-Of

On this page

Status

Step Description Status

1

Plan filing + ADR-028 (Service Identity + On-Behalf-Of, modeled on canopy ADR-019, 2026-05-08) in Proposed status. GitLab epic + 15 step issues. nav.adoc Active entry. CHANGELOG entry. 0 production code changes.

Done (2026-05-09) — MR !235; Step 2 added 2026-05-09 per external review

2

OIDC Discovery convergence in craig-auth + craig-web (added 2026-05-09 per external canopy/craig comparative review). Currently crates/craig-auth/src/jwks.rs:81 builds Keycloak /realms/<realm>/protocol/openid-connect/certs URLs directly from oidc_issuer; services/craig-web/src/auth.rs:198 constructs Keycloak token-endpoint paths the same way. Both paths violate ADR-026’s IdP-neutrality stance even though ADR-026 is Accepted. This step replaces both with proper OIDC discovery: at boot, fetch <issuer>/.well-known/openid-configuration, cache the discovered jwks_uri, authorization_endpoint, token_endpoint, end_session_endpoint. Refresh discovery doc on a TTL (default 1h) and on kid cache misses. Rename settings: oidc_issueroidc_issuer with backwards-compat env var alias for one release. Bring craig’s stricter JWT validation along (aud/azp/scope/typ + nbf checks per claims.rs:19, jwks.rs:146); canopy currently lacks these and should adopt them as part of the same convergence. + Files: crates/craig-auth/src/{jwks.rs,middleware.rs,settings.rs}, services/craig-web/src/auth.rs, crates/craig-common/src/settings.rs (rename + alias), tests in crates/craig-auth/tests/. Documentation updates: idp-integration.adoc, .claude/docs/security.md. Foundations for every subsequent Plan E step — Step 3’s OidcServiceToken reuses the discovery client; Step 11’s xtask identity verify consumes the same contract.

Done (pre-ADR-030) — Step 2: !248 — discovery client + JwksProvider + craig-web flows; Step 2b: this MR — destructive rename keycloak_issueroidc_issuer and keycloak_urloidc_internal_url across Rust + compose + .env.example + all docs; no backwards-compat alias per pre-1.0 policy. Stricter JWT validation already shipped pre-Plan-E.

3

ADR-028 acceptance + Claims API extensions in crates/craig-auth/src/claims.rs. Add Claims::is_service(&self) → bool (true when the configured roles claim path contains an entry starting with CRAIG_IDENTITY_SERVICE_ROLE_PREFIX, default service:), Claims::service_id(&self) → Option<&str> (matched suffix; fallback to azp), Claims::require_service_caller(&self) → Result<(), ApiError> (Forbidden if not a service caller). Add pub actor: Option<Box<Claims>> field with #[serde(skip)] so middleware lifts it in after validating X-Craig-Actor. Add Claims::roles() lookup honoring CRAIG_IDENTITY_ROLES_CLAIM_PATH (default realm_access.roles; configurable for Okta groups, Azure AD roles, custom paths). Unit tests cover each predicate.

Done (pre-ADR-030) — deviation: configurable CRAIG_IDENTITY_ROLES_CLAIM_PATH deferred to Step 3b — keeping the typed realm_access.roles field for now since changing it to dynamic-path resolution requires capturing raw claims; the CRAIG_IDENTITY_SERVICE_ROLE_PREFIX env var ships in this step. Step 4’s outbound-flip can land before Step 3b without blocking on the configurable path.

4

craig-auth::OidcServiceToken (replaces services/craig-intake/src/api/service_token.rs::KeycloakServiceToken AND services/craig-cases/src/authz_bootstrap.rs ROPC stopgap from Plan A Step 8). New module crates/craig-auth/src/service_token.rs. Uses the OIDC-discovery client from Step 2 + client_credentials grant (was ROPC + username/password). Constructor takes service_name, client_id, client_secret from secrets and the craig-identity issuer URL (from CRAIG_IDENTITY_INTERNAL_URL or fallback CRAIG_IDENTITY_ISSUER). Caches the JWT in-process; refreshes 5 minutes before expiry. Public API: async fn current(&self) → Result<String, ServiceTokenError> — returns cached token, blocks if a refresh is in flight, returns most-recent-known token if refresh fails (fail-closed at expiry). Workspace dep: oauth2 = "5.0". Delete services/craig-intake/src/api/service_token.rs (move + rename); delete services/craig-cases/src/authz_bootstrap.rs; update services/craig-intake/src/api/{partner_auth,signer_auth}.rs + services/craig-intake/src/sink/cases_forwarder.rs + craig-cases authz-engine boot to use the new shared crate.

Done (pre-ADR-030) — deviations: TOKEN_REFRESH_LEAD_TIME 30s reused from craig-common::constants; no new oauth2 dep, direct reqwest::Client::post(token_endpoint — .form([client_credentials, client_id, client_secret]) instead. Service-role check (is_service, service_id) propagated to JDM input via build_check_input + build_scope_input so rulesets match on claims.is_service. 98 rulesets gained an i_service input + service-caller rule. Claims::require_* helpers updated to OR-in is_service() so the ~17 remaining role-gated sites in craig-cases reports/persons handlers admit service callers without rewriting them.)

5

craig-signing::ActorTokenIssuer. New module crates/craig-signing/src/actor_token.rs. Reuses each craig-* service’s existing canopy-signing-style keypair (already used for ADR-018 partner JWS — same key, distinct aud namespace craig-internal-actor). Mints actor JWTs with 10-minute TTL carrying the worker’s normalized claims (sub, preferred_username, configured roles claim). Distinct aud namespace separates this from partner JWS signing — a leaked partner JWS doesn’t grant actor authority and vice versa. Unit tests cover round-trip + signature verification.

Done (pre-ADR-030) — deviations: module placed in crates/craig-auth/src/actor_token.rs, not craig-signing — keeping craig-signing minimal preserves its mandate as the cross-language canonicalize_json contract for SDK consumers. CRAIG services don’t have a pre-existing keypair to reuse — partner JWS infrastructure verifies partner-supplied keys but services themselves don’t yet sign anything. Step 8 (bootstrap wiring — will provision per-service ES256 keypairs; Step 5 ships the issuer + WorkerActor projection type + 7 unit tests covering round-trip, signature verification, and tampered-payload / wrong-key rejection.)

6

Auth middleware: actor extraction. Update crates/craig-auth/src/middleware.rs::auth_middleware. (a) Validate the bearer against craig-identity’s JWKS via the discovery-cached jwks_uri from Step 2. (b) If Claims::is_service() is true AND the request carries X-Craig-Actor: <jwt>, validate the actor JWT against craig-signing’s JWKS, check aud == "craig-internal-actor", set claims.actor = Some(Box::new(actor_claims)). (c) Inject the (possibly actor-enriched) Claims extension. Reject if actor JWT validation fails — never silently drop. craig-signing’s JWKS is fetched once at startup and cached; refresh on kid cache miss. New integration test asserts actor-extension propagates end-to-end through a real handler.

Done (pre-ADR-030) — deviations: per-CRAIG-service JWKS via ActorJwksRegistry trait, not a single "craig-signing" JWKS — craig-signing is a library not a service, and each CRAIG service signs with its own keypair per Step 5; the registry maps (iss, kid — ` → JWK. `StaticActorJwksRegistry HashMap-backed default impl ships in this step for tests + Step 8’s bootstrap wiring. Verifier enforces signature, fixed aud = "craig-internal-actor", iss, TTL ceiling (10min + 30s clock-skew tolerance), and iat not-in-future. Workers carrying X-Craig-Actor are log+ignored (header is meaningless without service-caller privilege). Header on service caller with no registry configured → 401 fail-closed. 4 middleware integration tests + 8 inline verifier unit tests.)

7

Outbound helpers in crates/craig-auth/src/client_ext.rs (new module):

* RequestBuilder::with_service_identity(self, token_source: &OidcServiceToken) → Self — replaces existing .bearer_auth(worker_token) calls; fetches the current craig-identity-issued service token from the source. * RequestBuilder::with_actor(self, actor: Option<&Claims>) → Self — when Some, mints an actor JWT via ActorTokenIssuer and attaches as X-Craig-Actor. When None (drainer publishes, scheduled jobs with no worker context), no header attached.

Internal services that don’t need user identity call .with_service_identity(…​) only.

Done (pre-ADR-030) — deviations: with_service_identity is async (Rust 2024 native async fn in trait — , not sync — Step 4’s OidcServiceToken::current is async, so a blocking variant would deadlock in async runtime context. with_actor takes an explicit &ActorTokenIssuer parameter (not a global) and returns Result<Self, ActorTokenError> since signing is fallible at the clock boundary. Added third combined helper with_craig_identity(svc, issuer, claims) → Result<Self, OutboundIdentityError> so Step 9/10 callers can do both attachments in one await rather than chaining .await? + ? mid-builder. WorkerActor::from_claims(&Claims) projection lives here (inherent impl on WorkerActor) to keep Claims out of crate::actor_token. 6 inline tests cover bearer attachment via a fake OIDC token endpoint, actor None/Some, combined helper with/without actor, and from_claims filtering.)

8

Bootstrap wiring. crates/craig-api/src/lib.rs::bootstrap constructs an OidcServiceToken per service (using the service’s CRAIG_<SVC>_CLIENT_ID and CRAIG_<SVC>_CLIENT_SECRET) and an ActorTokenIssuer (using the service’s existing craig-signing keypair). Both stored on BootstrapResult so handlers can inject them as Axum Extension`s. The reqwest `Client extension ClientExt::craig_internal() returns a builder pre-configured with the token source + actor issuer.

Done (pre-ADR-030) — deviations: no ClientExt::craig_internal( — ` pre-bundled builder — Step 7 already ships per-method extension helpers + a combined `with_craig_identity on RequestBuilder; an additional sugar layer would duplicate the surface without adding power. BootstrapResult gains service_token: Option<OidcServiceToken> + actor_issuer: Option<ActorTokenIssuer>Option so services that haven’t been provisioned with CLIENT_ID/CLIENT_SECRET or SIGNING_JWK/SIGNING_KID env vars boot gracefully. Per-service keypair loaded from inline <prefix>SIGNING_JWK (no file-path variant yet — deferred until production deployment); peer JWKS map loaded once deployment-wide from CRAIG_PEER_JWKS_JSON (JSON array of {iss, kid, jwk} entries). AuthLayer is wired with the peer-JWKS registry at bootstrap so Step 6’s actor-verification path is active when registry is non-empty. All 6 standard services migrated to consume bootstrap-provided service_token (deletes the duplicate OidcServiceToken construction Step 4 placed in each service’s main.rs); craig-rules discards both with patterns. 5 inline unit tests for the env loaders. Devstack CRAIG_PEER_JWKS_JSON + CRAIG<SVC>SIGNING_JWK/KID provisioning deferred to Step 14.)

9

craig-web BFF flip. services/craig-web/src/api_client.rs:28,44,61,72,98,114 — every bearer_auth(token) becomes with_service_identity(&svc_token).with_actor(Some(&worker_claims)). The 6 call sites currently forward the worker’s bearer to backend services (cases, placement, exchange, financial, reporting, security, rules, intake); after this flip, the bearer is craig-web’s own client_credentials token + the worker identity travels via X-Craig-Actor. Inbound craig-web handlers continue to expect worker JWTs validated against craig-identity’s JWKS — same JWKS, different aud. End-to-end Playwright test pins the contract.

Done (pre-ADR-030) — Step 9a (provisioning + craig-web bootstrap wiring). Step 9b complete (ApiClient flip + caller updates). 9b deviations: the actual surface was ~91 call sites (not 25 — fetch_page + fetch_page_or_empty + batch_lookup helpers had to be flipped too, and each helper has many callers in routes/). Method signatures changed from token: &str to user: &SessionUser (craig-web’s projected session shape, not full ClaimsSessionUser has all the fields the actor JWT carries: sub, username, roles). ApiClient::apply_identity is the internal helper that decides: when both service_token + actor_issuer are Some, sets Authorization: Bearer <client_credentials_token> + X-Craig-Actor: <signed actor JWT>; otherwise falls back to bearer_auth(user.access_token) (legacy). Doesn’t use the Step 7 CraigClientExt::with_craig_identity extension trait — that trait takes &Claims and would require synthesizing a Claims from SessionUser; minting WorkerActor directly from SessionUser fields is cleaner. 1 new ApiClient fallback test (fallback_to_worker_bearer_when_no_service_identity). 1879/1879 workspace tests pass.

10

craig-intake flip. services/craig-intake/src/api/{partner_auth,signer_auth,attachments}.rs + services/craig-intake/src/sink/cases_forwarder.rs — flip from the renamed OidcServiceToken (Step 4) to with_service_identity(…​).with_actor(…​) per Step 7. Partner-edge calls (intake → security /partners/verify, intake → security /signer-keys/by-kid) carry the partner’s PartnerContext::Authenticated (when applicable) as the actor claim so the audit log on the security side can attribute the call to the right partner.

Done (pre-ADR-030) — deviation: partner-as-actor attribution deferred to Step 11. The actor JWT contract Step 5 designed (WorkerActor { sub, preferred_username, roles } — targets workers; encoding a partner identity in it would conflate two distinct principal types and require extending the verifier’s downstream contract. Step 10 lands the bearer flip — all 4 outbound paths (partner_auth, signer_auth, cases_forwarder, attachments) now route through Step 7’s CraigClientExt::with_service_identity instead of manually constructing bearer_auth(&token) — and leaves partner attribution to the audit-row body (cases-side audit reads partner_id from the report payload; security-side reads it from the partner verify request). CasesForwarderSink::service_token() accessor added so call sites borrow the OidcServiceToken directly. bearer_token() string-helper retired. 1879/1879 tests pass.)

11

Audit-log enrichment. services/craig-security/src/store/audit.rs::insert_audit_entry reads both claims.service_id() (caller service) and claims.actor.as_ref().map(|a| &a.sub) (acting worker). New columns: actor_service TEXT NOT NULL DEFAULT 'unknown', actor_user_sub UUID. Pre-1.0 destructive migration adds the columns; existing rows backfill actor_service = 'unknown'. Wildcard subscriber updated to populate both fields from envelope context. Plan B Step 3 (reports-encryption) lands AFTER this enrichment so the encrypted-report audit rows carry caller-service attribution from the start.

Done (pre-ADR-030) — deviation: only audit insert path in craig-security today is the wildcard subscriber handle_inbound_event — there’s no direct &Claims-bearing in-process caller. InsertAuditEntryParams gains actor_service: String + actor_user_sub: Option<Uuid> so future direct callers can populate from claims.service_id( — ` / `claims.actor.as_ref(); the subscriber populates from EventEnvelope.source_service + a new event_parsing::extract_actor_user_sub helper that parses created_by/approved_by/user_id/archived_by as UUID. AuditQuery + list/count store params extended with the same fields so the BFF can pivot the audit view on caller-service / acting-worker. 9 new tests (7 inline event_parsing unit tests covering each payload-field priority + system identifier + legacy preferred_username rejection + no-attribution fallback; 2 devstack integration tests verifying actor_service is non-empty on every row and the filter narrows correctly). 1888/1888 workspace tests pass post-reseed.)

12

cargo xtask identity verify. New xtask command in xtask/src/cmd/identity.rs. Behavior: (a) hits <issuer>/.well-known/openid-configuration, verifies the four required endpoints (authorization_endpoint, token_endpoint, jwks_uri, optionally end_session_endpoint); (b) fetches jwks_uri, parses JWKs; (c) attempts a client_credentials grant using a configured test service principal, validates the response token shape against the contract; (d) reports per-check pass/fail with diagnostics. Read-only: no mutation of the backend. Safe in dev, CI, and production deploy gating. Args: --issuer <url> overrides CRAIG_IDENTITY_ISSUER; --client-id and --client-secret for the test principal. Wired into cargo xtask validate step [6c/14] as a soft check (warning only) so devstack contributors aren’t blocked when running without an OIDC issuer; production deploy pipelines run it as a hard gate.

Done (pre-ADR-030) — deviations: (1 — authorization_code-with-PKCE worker probe deferred — the plan body called for an optional second probe verifying worker tokens, but that needs an interactive browser callback dance that doesn’t fit a CI/deploy-gate context; the client_credentials probe is sufficient for the service-identity contract that’s Plan E’s core. A worker-token probe can ship as a successor cargo xtask identity verify --worker-only once a headless PKCE flow is justified. (2) The check distinguishes three outcomes — AllPass, Failures(Vec<String>), and Inconclusive(String) — so validate.rs can downgrade discovery-unreachable to a warning without conflating it with real contract failures. (3) Env vars resolve via --flag → CRAIG_IDENTITY_* precedence, plus `.filter(

s

!s.is_empty())` so empty strings count as absent (the recurring shell-export-with-no-value foot-gun). (4) 9 inline unit tests cover the full matrix: discovery + JWKS + token success; no-test-principal-skips-token-probe; discovery unreachable; required endpoint missing; empty JWKS; empty access_token; non-Bearer token_type; case-insensitive bearer; URL builder trims trailing slash. (5) Soft-check wiring at [6c/14] silently SKIPs when CRAIG_IDENTITY_ISSUER isn’t set so the local pre-push gate isn’t blocked. 1897/1897 workspace tests pass.)

13

cargo xtask identity render --backend <name>. New subcommand emitting reference IaC fragments for the supported backends. Pure code generation, no network, no mutation. Initial backends shipped:

* --backend keycloak [--out realm.json] — emits a Keycloak realm definition with the worker realm + 9 service-account clients (8 craig backends + 1 craig-web BFF) with audience mappers + service:craig-* realm roles. Operators merge it into their Keycloak Operator CR / Helm chart / Terraform Keycloak provider config. * --backend authentik [--out blueprint.yaml] — emits an Authentik blueprint covering the same shape. * --backend dex [--out dex.yaml] — emits a Dex static-clients + connector-config template.

Backends without a render adapter (Okta, Entra, ForgeRock, PingFederate, custom) require operator-side configuration in whatever tooling the operator already uses. The contract definition above + the conformance test give them the spec they need. Templates use tera (already a workspace dep transitively); per-backend snapshot tests pin the rendered output.

Done (pre-ADR-030) — deviations: (1 — module reshaped into a directory (xtask/src/cmd/identity/{mod,verify,model,render/{mod,keycloak,authentik,dex}}.rs) — single-file identity.rs got too dense once Step 13’s render path joined Step 12’s verify path. The reshape keeps each backend renderer as an independently-testable sibling. (2) tera template engine NOT pulled in — the canonical model is small and stable enough that hand-rolled serde_json::json!() (Keycloak) + hand-rolled writeln! YAML (Authentik, Dex) is simpler than a template engine. Zero new workspace dependencies. Empty-string YAML quoting + reserved-char detection in authentik.rs::quote_yaml covers the cases this output actually emits. (3) Canonical IdentityModel in model.rs is the source of truth — all three renderers project from the same struct (realm name, 9 service ids, 6 worker roles, 9 service roles, 2 user-facing clients, placeholder service secret, optional 3 dev users with pinned UUIDs matching .claude/CLAUDE.md). (4) Two output modes via --production flag: devstack-friendly (3 dev users + callback URIs filled) vs production-clean (no dev users, empty URIs with operator hint). (5) 43 inline tests cover the matrix: 5 model invariants (service count, role count, pinned UUIDs, devstack-vs-production defaults), 7 render dispatcher tests (per-backend dispatch + realm validation), 11 Keycloak structural tests (all roles emitted, all clients have service-accounts + audience mappers + placeholder secret, dev users gated by mode, output is valid JSON), 10 Authentik tests (version line, groups, oauth2providers, dev-user gating, YAML quoting helper), 9 Dex tests (staticClients section, all 9 services, redirect URIs, dev-users-note gating). 1940/1940 workspace tests pass — up from 1897. Smoke-rendered all 3 backends end-to-end against the live CLI.)

14

Devstack provisioning. devstack/keycloak/craig-realm.json + secrets/devstack .env updates: 9 service-account clients with placeholder secrets (replaced by cargo xtask dev identity provision on first cargo xtask dev start). New cargo xtask dev identity provision subcommand mutates the devstack Keycloak via admin API to install/update the craig realm based on the rendered realm.json (Step 13), generates per-stack client secrets, writes them encrypted into the devstack secrets file. Idempotent: re-running with existing state is a no-op unless --rotate <service> is passed. Production deployers do NOT run this command — they provision via their own IaC and use xtask identity verify (Step 12) for conformance gating.

Done (pre-ADR-030) — Step 14a (deviations: scope split into 14a + 14b. Step 14a (this MR — production-readiness MVP) shipped: (1) File-path env support: crates/craig-auth/src/keypair_env.rs load_signing_keypair_from_env now consults CRAIG_<SVC>SIGNING_JWK_FILE when the inline SIGNING_JWK is absent; load_peer_jwks_from_env adds the same precedence for CRAIG_PEER_JWKS_JSON_FILE. Empty-string normalization on both. Production deployments mount JWKs as Docker / Kubernetes secrets at a path; the key never enters the process env table. (2) cargo xtask gen-actor-keys --kid-suffix <suffix> [--out <path>] subcommand promotes the throwaway one-off generator (previously documented in agent memory only) to a tested + supported xtask. Output matches the committed devstack/devstack-actor-keys.env shape. (3) Rotation procedure docs added to docs/modules/ROOT/pages/idp-integration.adoc § Service Identity Keypair Provisioning — generation, devstack vs production env-var modes, 6-step rotation flow (pre-rotation → advertise → rotate → drain → retire → audit), emergency-rotation variant. (4) Committed devstack/devstack-actor-keys.env header updated to reference the new subcommand + production file-mount alternative. 14 new tests (4 keypair_env file-path + 10 gen_actor_keys). 1954/1954 workspace pass. Step 14b deferred (xtask dev identity provision admin-API integration): devstack works fine today via the committed realm.json, and the admin-API integration is meaningful complexity for marginal devstack-reproducibility gain. Per ADR-026 IdP-neutrality, talking to a Keycloak admin API at all is a controlled exception that’s hard to justify when the bootstrap path is already 1-shot via the committed JSON. If a future need surfaces (e.g. automated rotation drills, testing realm-config migrations), 14b lands as a successor MR. Tracked in plan body Errata.)

15

Issue: epic &28
Issues: #369–#382 (Steps 1, 3–15) + new issue for Step 2 (filed by the scope-expansion MR)
Branch prefix: feat/service-identity- / fix/service-identity- / chore/service-identity-
*Milestone
: TBD

Context

Origin: canopy ADR-019 (2026-05-08, canopy) — sibling CCWIS-adjacent project — established canopy-identity as a contract (per-service client_credentials + X-Canopy-Actor on-behalf-of header signed by canopy-signing). Canopy ADR-019 explicitly cites craig ADR-026 as the worker-identity model and adopts it verbatim; craig adopts canopy’s service-identity layer in return to close the symmetric gap craig has on the receiving side.

craig’s specific gaps (audited 2026-05-09):

  1. JWT pass-through in craig-web BFF (6 call sites)services/craig-web/src/api_client.rs:28,44,61,72,98,114 forward the worker’s bearer to backend services. The audit_log row at the receiving service can’t distinguish "worker hit cases via web BFF" from "worker hit cases directly via CLI" — same anti-pattern canopy is fixing in canopy-web.

  2. Audit log lacks caller-service attributionservices/craig-security/migrations/20260305100000_create_security_tables.sql audit_log has user_id, user_role, service but the service column is the target service, not the caller. No actor_service, no actor_user_sub. Same gap canopy fixes in their ADR-019 Step 15.

  3. KeycloakServiceToken is IdP-coupledservices/craig-intake/src/api/service_token.rs:55 — the type name itself violates ADR-026’s IdP-neutrality stance, and it’s ROPC-based (username + password) rather than client_credentials grant. Embarrassing for a project whose ADR-026 just landed.

Why per-service client_credentials (not a shared service credential) — confirmed during plan-shaping (2026-05-09):

  • Auditability. The bearer JWT’s azp claim tells the receiving service which service called. With a single shared craig-internal credential, every receiver sees the same azp — you can’t distinguish craig-web from craig-cli from craig-intake at the audit layer. Even WITH actor JWTs (telling you the worker), you still need per-service azp to distinguish callers.

  • Blast radius on credential leak. Rotating one service’s secret vs. fleet rotation.

  • Permission scoping. craig-intake clearly needs to talk to cases + security only, not financial. Shared creds can’t express that.

  • Onboarding clarity. xtask identity render produces N predictable client definitions; operators script it.

Why ship authentik + dex render adapters in Plan E (not just keycloak) — ADR-026’s IdP-neutrality stance demands it. Shipping with Keycloak-only xtask identity render defeats the ADR’s whole point. The marginal cost of two more tera templates is bounded; the operational signal of "we genuinely support multiple IdP backends" is not.

Differences from canopy ADR-019

  1. Soft-cutover, not hard. Canopy’s plan is a hard cutover — internal services stop accepting worker JWTs. Craig’s call graph is smaller (~3 sync-call gaps vs. canopy’s ~30) and many craig endpoints are dual-purpose (BFF caller + direct CLI/API caller). Internal services keep accepting BOTH worker JWTs (so direct integration tests + CLI continue to work) AND service-identity tokens; craig-web + craig-intake start sending service-identity + X-Craig-Actor onward; audit log captures both. No breaking change to integration test harness.

  2. 9 service-account clients, not 13. Canopy needs 13 (more programs); craig has 8 backends + 1 BFF.

  3. Reuse craig-signing keypair, distinct aud namespace. Same as canopy. The aud: craig-internal-actor namespace separates actor JWTs from the existing partner-JWS signing (aud: craig-intake).

Relationship to other plans

  • Plan A (multi-juris-authz): Steps 1-5 already shipped. Plan E builds on Plan A’s ResourceType × Action + Claims shape. Plan A Steps 6-14 continue in parallel; Plan E does not block them.

  • Plan B (PII + races): Plan B Step 3 (reports-encryption) lands on the audit-log shape Plan E enriches. Plan E Step 10 must merge before Plan B Step 3.

  • Plan C (partner edge): Independent. Plan C’s partner-flow tightening (JWS replay, signer-key expiry, etc.) is orthogonal to internal service-identity.

  • Plan D (code-quality discipline): Independent.

Scope

In scope:

  • craig-identity contract definition (env vars, OIDC discovery requirements, token shape, claim path conventions).

  • Claims API extensions (is_service, service_id, require_service_caller, actor, configurable roles() lookup).

  • OidcServiceToken (craig-auth) — OAuth2 client_credentials wrapper with refresh; replaces ROPC-based KeycloakServiceToken.

  • ActorTokenIssuer (craig-signing) — service-signed actor JWTs.

  • Auth middleware actor-header extraction + craig-signing JWKS validation path.

  • Outbound helpers (with_service_identity, with_actor).

  • craig-web BFF + craig-intake flip from JWT pass-through to service identity.

  • Audit-log enrichment with caller-service + on-behalf-of-user.

  • cargo xtask identity verify — read-only conformance test.

  • cargo xtask identity render --backend {keycloak,authentik,dex} — reference IaC fragment emission.

  • cargo xtask dev identity provision — devstack-only Keycloak realm provisioning.

  • devstack craig-realm.json extension + devstack secrets entries.

  • Documentation of the contract for deployers (idp-integration.adoc or equivalent).

Out of scope:

  • Production identity-backend lifecycle tooling. CRAIG does not own provisioning, secret rotation, or admin operations against deployer-owned IAM backends. Deployers use their existing IaC.

  • A craig-identity service container. craig-identity is a contract, not a service we ship. Operators deploy any compliant OIDC issuer.

  • mTLS between services (transport-layer; could layer later).

  • Token-binding (RFC 8473).

  • Per-call audience scoping.

  • Removing worker JWTs from worker-facing entry points (BFFs, partner intake).

  • Hard cutover (rejecting worker JWTs at internal endpoints) — soft-cutover allows direct CLI / integration test access.

Design

D1. The craig-identity contract (operator-facing)

Variable Meaning

CRAIG_IDENTITY_ISSUER

OIDC issuer URL.

CRAIG_IDENTITY_INTERNAL_URL

Optional in-cluster network locator for the issuer.

CRAIG_IDENTITY_AUDIENCE

Audience for service tokens. Default craig-internal-service.

CRAIG_IDENTITY_ROLES_CLAIM_PATH

JSON path to roles array. Default realm_access.roles.

CRAIG_IDENTITY_SERVICE_ROLE_PREFIX

Service-role marker. Default service:.

CRAIG_<SERVICE>_CLIENT_ID

Per-service OAuth2 client_id (e.g. CRAIG_WEB_CLIENT_ID=craig-web).

CRAIG_<SERVICE>_CLIENT_SECRET

Per-service OAuth2 client_secret.

Required OIDC discovery endpoints: authorization_endpoint, token_endpoint, jwks_uri. Optional: end_session_endpoint.

Required service token shape (issued via client_credentials): iss matches CRAIG_IDENTITY_ISSUER, stable sub, azp identifies the calling service, aud includes craig-internal-service, exp + iat standard, roles claim contains an entry starting with CRAIG_IDENTITY_SERVICE_ROLE_PREFIX (default service:craig-<name>).

Worker tokens (issued via authorization_code to a BFF client): iss matches CRAIG_IDENTITY_ISSUER, sub is the worker’s stable identifier, aud is the requesting BFF client (craig-ui, craig-api, etc.), preferred_username, email standard OIDC, roles claim contains worker roles (no service:* entries).

Claims deserialization tolerates either string or array aud (per ADR-021’s aud_or_vec deserializer). Roles are looked up via the configured path with a default-Keycloak fallback.

D1.5. OIDC Discovery client (Step 2)

Per the 2026-05-09 external review, ADR-026 IdP-neutrality is currently violated by code paths that build Keycloak URLs directly:

  • crates/craig-auth/src/jwks.rs:81 constructs <issuer>/protocol/openid-connect/certs instead of fetching the discovered jwks_uri.

  • services/craig-web/src/auth.rs:198 constructs Keycloak token-endpoint paths the same way.

Step 2 introduces a discovery client in craig-auth:

// crates/craig-auth/src/oidc_discovery.rs (new)
pub struct OidcDiscovery {
    pub issuer: String,
    pub jwks_uri: String,
    pub authorization_endpoint: String,
    pub token_endpoint: String,
    pub end_session_endpoint: Option<String>,
    fetched_at: Instant,
}

impl OidcDiscovery {
    pub async fn fetch(http: &reqwest::Client, issuer: &str) -> Result<Self> {
        let url = format!("{issuer}/.well-known/openid-configuration");
        let doc: DiscoveryDocument = http.get(&url).send().await?.error_for_status()?.json().await?;
        // Validate required endpoints, normalize trailing slashes, etc.
        ...
    }

    pub fn is_stale(&self, ttl: Duration) -> bool { ... }
}

Cached at boot; refreshed on TTL expiry (default 1h) and on kid cache miss against the JWKS. Existing Keycloak-construction sites in jwks.rs:81 and auth.rs:198 switch to consume the discovered URLs.

Settings rename: oidc_issueroidc_issuer with one-release backwards-compat env-var alias CRAIG_<SVC>__OIDC_ISSUER. Documentation updates under docs/modules/ROOT/pages/idp-integration.adoc describe the migration for operators.

Bring craig’s stricter JWT validation along (aud/azp/scope/typ/nbf checks per crates/craig-auth/src/claims.rs:19 and jwks.rs:146); canopy’s parallel review noted these as missing on canopy’s side and recommended adoption.

D2. Wire shape (post-cutover)

Worker → craig-web (worker JWT issued by craig-identity):

POST /cases/{id}/actions/file-appeal HTTP/1.1
Authorization: Bearer eyJ... (worker JWT, iss=craig-identity-issuer, aud=craig-ui)

craig-web → craig-cases (service token issued by craig-identity + actor JWT signed by craig-web):

POST /v1/cases HTTP/1.1
Authorization: Bearer eyJ... (service token, iss=craig-identity-issuer, azp=craig-web, aud=craig-internal-service, roles=[service:craig-web])
X-Craig-Actor: eyJ... (craig-web-signed actor JWT, sub=jane.doe.uuid, aud=craig-internal-actor, exp=now+10m)

craig-cases audit row (post-Step 10):

INSERT INTO audit_log (..., actor_service, actor_user_sub, ...)
VALUES (..., 'craig-web', 'jane.doe.uuid', ...);

D3. Trust topology

  • craig-identity issuer (whatever the operator deploys) publishes JWKS for worker + service token validation.

  • craig-signing publishes its own JWKS for actor JWT validation. Distinct from craig-identity JWKS.

  • Each craig-* service trusts:

    • craig-identity JWKS for bearer-token validation (workers AND services, same JWKS, different aud).

    • craig-signing JWKS for actor-JWT validation (X-Craig-Actor header).

  • Cross-stack: each jurisdiction’s stack uses its own craig-identity issuer + craig-signing keys. Cross-stack tokens fail signature verification.

D4. Claims API additions

impl Claims {
    pub fn is_service(&self) -> bool {
        self.roles().iter().any(|r| r.starts_with(SERVICE_ROLE_PREFIX))
    }

    pub fn service_id(&self) -> Option<&str> {
        self.roles().iter()
            .find_map(|r| r.strip_prefix(SERVICE_ROLE_PREFIX))
            .or(self.azp.as_deref())
    }

    pub fn require_service_caller(&self) -> Result<(), ApiError> {
        if self.is_service() { Ok(()) } else { Err(ApiError::Forbidden) }
    }

    pub fn actor(&self) -> Option<&Claims> { self.actor.as_deref() }

    pub fn roles(&self) -> &[String] { /* honors CRAIG_IDENTITY_ROLES_CLAIM_PATH */ }
}

Claims::actor is Option<Box<Claims>> with #[serde(skip)] so it never round-trips through JWT serialization — middleware lifts it in after validating X-Craig-Actor.

D5. Receiving-side validation (auth middleware)

  1. Extract Authorization: Bearer <token>. Validate against craig-identity’s JWKS (cached at startup, refreshed on kid cache miss). Validate aud matches per-endpoint expectation: craig-internal-service for internal endpoints, the BFF client for worker-facing entry points. Validate exp, iss.

  2. Look up the configured roles claim path. If any entry starts with CRAIG_IDENTITY_SERVICE_ROLE_PREFIX, mark Claims::is_service() == true.

  3. If the request also carries X-Craig-Actor, validate the actor JWT against craig-signing’s JWKS, check aud == "craig-internal-actor", set claims.actor = Some(Box::new(actor_claims)).

  4. Reject if either validation fails. Never silently drop.

D6. Outbound helpers

// crates/craig-auth/src/client_ext.rs
pub trait CraigClientExt {
    fn with_service_identity(self, source: &OidcServiceToken) -> Self;
    fn with_actor(self, actor: Option<&Claims>) -> Self;
}

impl CraigClientExt for reqwest::RequestBuilder {
    fn with_service_identity(self, source: &OidcServiceToken) -> Self {
        // Block on token fetch (cached; ~free in steady state).
        let token = source.current_blocking().expect("service token unavailable");
        self.bearer_auth(token)
    }

    fn with_actor(self, actor: Option<&Claims>) -> Self {
        match actor {
            Some(claims) => {
                let actor_jwt = ACTOR_ISSUER.mint(claims).expect("actor JWT mint");
                self.header("X-Craig-Actor", actor_jwt)
            }
            None => self,
        }
    }
}

Internal services that don’t need user identity call .with_service_identity(…​) only; drainer publishes + scheduled jobs that don’t carry worker context skip .with_actor(…​).

D7. ActorTokenIssuer

// crates/craig-signing/src/actor_token.rs
pub struct ActorTokenIssuer {
    keypair: Arc<SigningKeypair>,  // existing craig-signing keypair
    service_name: String,
}

impl ActorTokenIssuer {
    pub fn mint(&self, worker_claims: &Claims) -> Result<String, ActorTokenError> {
        let now = chrono::Utc::now().timestamp();
        let payload = json!({
            "iss": &self.service_name,
            "sub": worker_claims.sub,
            "preferred_username": worker_claims.preferred_username,
            "realm_access": { "roles": worker_claims.realm_access.roles },
            "aud": "craig-internal-actor",
            "exp": now + 600,
            "iat": now,
            "act_for": &self.service_name,
        });
        // Sign with ES256 using the existing craig-signing keypair.
        let jwt = sign_es256(&self.keypair, &payload)?;
        Ok(jwt)
    }
}

The 10-minute TTL bounds replay risk; if a service-call chain takes longer than 10 minutes (rare), the calling service mints a new actor JWT at the next hop.

D8. Audit-log enrichment

Migration:

-- services/craig-security/migrations/<TS>_actor_columns.sql
ALTER TABLE audit_log
    ADD COLUMN actor_service TEXT NOT NULL DEFAULT 'unknown',
    ADD COLUMN actor_user_sub UUID;

Insert path (in services/craig-security/src/store/audit.rs::insert_audit_entry):

let actor_service = claims.service_id().unwrap_or("unknown");
let actor_user_sub = claims.actor.as_ref()
    .map(|a| Uuid::parse_str(&a.sub).ok())
    .flatten();
sqlx::query(
    "INSERT INTO audit_log (..., actor_service, actor_user_sub, ...) \
     VALUES (..., $N, $M, ...)"
)
.bind(actor_service)
.bind(actor_user_sub)
...

Pre-1.0 destructive migration is acceptable per the project’s pre-launch posture — no production audit data to preserve.

D9. xtask identity verify (conformance gate)

Behavior per Plan E Step 11:

  1. Hits <issuer>/.well-known/openid-configuration. Verifies the four required endpoints are present.

  2. Fetches jwks_uri. Confirms it parses, contains usable signing keys.

  3. Performs a client_credentials grant using a test service principal (configured in the same env vars as a real craig service). Validates the response token against the contract: iss, aud, exp, role claim shape, audience.

  4. (If test worker creds available) Performs an authorization_code flow with PKCE against a test worker principal. Validates the resulting token similarly.

  5. Reports per-check pass/fail with diagnostic detail.

cargo xtask identity verify --issuer <url> is safe to run anywhere — dev, CI, production deployment-gating. It does not mutate the backend.

D10. xtask identity render (reference IaC)

Pure code generation. Emits IaC fragments for backends craig provides adapters for. Templates use tera (already a transitive workspace dep).

  • cargo xtask identity render --backend keycloak [--out realm.json] — Keycloak realm definition with worker realm + 9 service-account clients (8 backends + craig-web BFF) + audience mappers + service:craig-* realm roles.

  • cargo xtask identity render --backend authentik [--out blueprint.yaml] — Authentik blueprint covering the same shape.

  • cargo xtask identity render --backend dex [--out dex.yaml] — Dex static-clients + connector-config template.

Per-backend snapshot tests pin the rendered output against committed fixtures so refactors of the template file surface as visible test diffs rather than silent drift.

Files Touched (aggregate)

File Step

docs/modules/ROOT/pages/adrs/adr-028-service-identity.adoc

1 (new), 3 (Accepted)

docs/modules/ROOT/pages/plans/service-identity.adoc

1 (this plan)

crates/craig-auth/src/oidc_discovery.rs

2 (new — OIDC discovery client)

crates/craig-auth/src/jwks.rs

2 (consume discovered jwks_uri instead of building Keycloak path)

services/craig-web/src/auth.rs

2 (consume discovered authorization/token endpoints)

crates/craig-auth/src/claims.rs

3 (extensions: is_service, service_id, require_service_caller, actor, roles)

crates/craig-auth/src/service_token.rs

4 (new module — OidcServiceToken; replaces craig-intake’s KeycloakServiceToken AND craig-cases’s authz_bootstrap.rs ROPC)

crates/craig-auth/src/client_ext.rs

7 (new — with_service_identity, with_actor)

crates/craig-auth/src/middleware.rs

6 (actor extraction + craig-signing JWKS path)

crates/craig-auth/Cargo.toml

4 (oauth2 = "5.0")

crates/craig-signing/src/actor_token.rs

5 (new — ActorTokenIssuer)

crates/craig-api/src/lib.rs::bootstrap

8 (per-service OidcServiceToken + ActorTokenIssuer)

services/craig-intake/src/api/service_token.rs

4 (DELETE; moved to craig-auth)

services/craig-cases/src/authz_bootstrap.rs

4 (DELETE; replaced by OidcServiceToken)

services/craig-intake/src/api/{partner_auth,signer_auth,attachments}.rs

10 (with_service_identity + with_actor)

services/craig-intake/src/sink/cases_forwarder.rs

10 (same)

services/craig-web/src/api_client.rs

9 (6 call sites flipped)

services/craig-security/migrations/<TS>_actor_columns.sql

11 (new — destructive ALTER)

services/craig-security/src/store/audit.rs

11 (insert path populates actor_service + actor_user_sub)

xtask/src/cmd/identity.rs

12 (verify), 13 (render), 14 (dev provision)

xtask/src/cmd/identity/templates/keycloak/realm.json.tera

13 (new)

xtask/src/cmd/identity/templates/authentik/blueprint.yaml.tera

13 (new)

xtask/src/cmd/identity/templates/dex/config.yaml.tera

13 (new)

devstack/keycloak/craig-realm.json

14 (9 service-account clients + audience mappers + service:craig-* roles)

secrets/devstack/.env

14 (9 new CRAIG_<SVC>_CLIENT_ID + CRAIG_<SVC>_CLIENT_SECRET entries)

crates/craig-common/src/settings.rs

1, 2 (oidc_issuer rename + alias), 8 (env-var schema additions)

docs/modules/ROOT/pages/idp-integration.adoc (new or extend)

2 (OIDC discovery contract for operators), 15 (per-backend setup notes; xtask verify/render/dev-provision usage)

.claude/docs/security.md

15 (new "craig-identity contract" section)

.claude/docs/architecture.md

15 (request-flow narrative update)

CHANGELOG.adoc

per step

Verification

After every step:

  1. cargo xtask validate --skip-docker

  2. cargo nextest run --workspace — full suite green

  3. cargo xtask check-docs — Tier 1 docs untouched

Plan-wide:

  1. OIDC discovery smoke (Step 2): boot any craig-* service against the devstack Keycloak; assert oidc_discovery::fetch populates the cache + subsequent JWT validation uses the discovered jwks_uri. Negative test: invalid issuer URL → boot bails with diagnostic.

  2. Service-token-acquisition smoke (devstack-gated, post-Step 4): each craig-* service successfully exchanges client_credentials for a token at startup.

  3. Actor-propagation regression (devstack-gated, Steps 9-11 wired): post /cases/{id}/…​ as worker jane.doe via craig-web; assert craig-cases’s audit row has actor_service = "craig-web" AND actor_user_sub = jane.doe.sub.

  4. xtask identity verify smoke: runs against the devstack Keycloak realm (post-Step 14); asserts pass.

  5. xtask identity render snapshot tests: per-backend rendered output matches committed fixtures.

  6. Cross-stack token rejection: cross-stack tokens fail signature verification (negative test in Step 6).

  7. End-to-end Playwright (Step 9): worker → craig-web → craig-cases flow succeeds; audit row carries correct actor_service + actor_user_sub.

Documentation Updates

  • Per-step CHANGELOG entries (per step)

  • docs/modules/ROOT/pages/idp-integration.adoc — OIDC discovery contract (Step 2), conformance test usage (Step 12), reference IaC + per-backend setup notes (Step 13), Service Identity Keypair Provisioning + rotation procedure (Step 14a), tense sweep (Step 15)

  • .claude/docs/security.md — service-identity keypair env-var modes added to Secret Management; audit-log attribution mention (Steps 11, 14a)

  • .claude/docs/services.md — wildcard subscriber writes actor_service + actor_user_sub (Step 11)

  • .claude/CLAUDE.md Phase Status row — flipped to Complete with 14 step-MR refs + 1954 Rust tests (Step 15)

  • CHANGELOG.adoc per-step entries (Steps 2 through 14a) + Step 15 wrap-up

  • Plan archive — nav.adoc Plan E moved Active → "(none)", archive.adoc Plan E row added under Security & Compliance, roadmap.adoc Plan E section flipped to past-tense (Step 15)

  • ADR-028 status flipped Proposed → Accepted (Step 3)

  • ADR-027 forward-tense reference to "pending Plan E" updated (Step 15)

Risks

Risk Mitigation

Step 8 craig-web flip introduces Authorization-header issues that integration tests don’t catch

Soft-cutover: backends still accept worker JWTs, so direct CLI tests continue to pass; Playwright e2e validates the BFF flow in browser; phased rollout per service rather than big-bang flip

Step 10 audit-log migration breaks existing audit-row reads

Pre-1.0; reseed regenerates devstack data; production deploy hasn’t happened yet so no real audit data to migrate

Step 11 xtask identity verify blocks devstack CI when the devstack Keycloak isn’t fully provisioned yet (chicken-and-egg)

Soft-fail in cargo xtask validate (warning only); production deploy pipelines run it as hard gate

Step 13 dev provision touches the devstack Keycloak admin API; admin password leak risk

Devstack admin creds remain in secrets/devstack/.env (already encrypted via existing patterns); subcommand is namespaced under dev — clear it doesn’t run in production

Per-service client_credentials doubles secret management surface (9 clients × secret + rotation each)

xtask identity render automates client provisioning for the 3 supported backends; deployer’s existing IaC handles the rest. Documentation in idp-integration.adoc provides the spec for hand-rolled backends

Plan B Step 3 (reports encryption) accidentally lands before Plan E Step 10 (audit columns)

Plan B explicitly cross-references Plan E Step 10 in its sequencing constraints; both plans' status tables track the dependency

Errata

Step-by-step deviations from the original plan that landed in their respective MRs.

Step 14a deviations (2026-05-11)

Step 14 split into Step 14a (production-readiness MVP) and Step 14b (Keycloak admin-API automation, deferred). Step 14a ships the production blockers; 14b is tracked as a successor if and when a concrete devstack-reproducibility need surfaces.

  • Step 14a delivers three sub-tasks:

    • File-path env support in crates/craig-auth/src/keypair_env.rs. Two new env-var families: CRAIG_<SVC>__SIGNING_JWK_FILE (per-service signing-key file path) and CRAIG_PEER_JWKS_JSON_FILE (deployment-wide peer-JWKS file path). Loaders try inline first, fall back to file path, normalize empty strings to absent. Production deployments mount JWKs as Docker / Kubernetes secrets at a path so the private key never appears in /proc/<pid>/environ or container introspection output. The deferred-from-Step-8 production hook closes.

    • cargo xtask gen-actor-keys --kid-suffix <suffix> [--out <path>] — promotes the throwaway one-off Rust recipe (previously documented only in agent memory, now Service Identity keypair provisioning) to a tested + supported xtask subcommand. 9 ES256 keypairs + the peer-JWKS array, output matches the env-file shape keypair_env.rs already consumes. Production deployments run it, then write the same content to their secret-mount paths rather than reading the inline env-file.

    • Rotation procedure docs in docs/modules/ROOT/pages/idp-integration.adoc § Service Identity Keypair Provisioning. 6-step flow leveraging the peer-JWKS map’s ability to carry multiple entries per iss (pre-rotation → advertise both kids → rolling-restart each service → drain old TTL → retire old kid → audit). Emergency-rotation variant skips the drain window for compromised-key scenarios. References Step 11’s audit-log columns as the verification surface.

  • Step 14b deferred: cargo xtask dev identity provision (Keycloak admin-API integration) was on the original plan but doesn’t ship today. Rationale: (a) devstack already works via the committed devstack/keycloak/craig-realm.json + devstack-actor-keys.env; (b) Step 13’s cargo xtask identity render --backend keycloak produces a fresh realm.json on demand, so realm provisioning is fundamentally an "import the JSON" step that’s a single kc.sh import command rather than meaningful automation; (c) ADR-026 (IdP-neutrality) makes a Keycloak admin-API client a controlled exception that’s hard to justify when it’s only used for one-shot devstack bootstrap; (d) production deploys are explicitly NOT meant to run this — the plan body itself says "Production deployers do NOT run this command — they provision via their own IaC". The net is: Step 14b is convenience tooling for devstack-rotation drills, not a production-readiness blocker. If a concrete need surfaces (CI test of full rotation flow against a real Keycloak; testing realm-config migrations), it lands as a successor MR. Not blocking Plan E archive (Step 15).

  • Committed devstack env-file header updated: devstack/devstack-actor-keys.env now references the new cargo xtask gen-actor-keys subcommand for regeneration and points at the file-mount production alternative. Keys themselves are unchanged — no devstack reseed required to merge this MR.

  • Tests: 14 new (4 keypair_env file-path: round-trip via tempfile + empty-file detection + env-var constant pinning + peer-JWKS roundtrip; 10 gen_actor_keys: env-prefix conversion, multi-dash service-name handling, 9-services-unique-kids invariant, kid-format pinning, peer-JWKS array shape, private/public JWK round-trip, env-file structure, production-warning header check, empty-kid-suffix rejection, rotation-suffix-changes-kid). 1954/1954 workspace pass.

  • Workspace deps: p256 = "0.13" + rand_core = "0.6" added to xtask/Cargo.toml. Same crates + versions already in craig-auth + craig-signing — net workspace size unchanged. tempfile added to craig-auth/Cargo.toml dev-deps (the file-mount round-trip test needs a tempfile; tempfile is already a workspace dep).

Step 13 deviations (2026-05-11)

Step 13 ships cargo xtask identity render --backend keycloak|authentik|dex — reference IaC generator. Pure code generation, no network, no mutation. Three backend renderers in xtask/src/cmd/identity/render/.

  • Module reshape: xtask/src/cmd/identity.rs (single file from Step 12) became xtask/src/cmd/identity/ (directory) with siblings mod.rs, verify.rs (from Step 12), model.rs, render.rs, render/keycloak.rs, render/authentik.rs, render/dex.rs. The reshape is purely organizational — each renderer is independently testable, the canonical IdentityModel lives in model.rs, and validate.rs’s soft-check wiring is updated from `super::identity::* to super::identity::verify::*. No public surface change.

  • No template engine: the plan body said "Templates use tera (already a transitive workspace dep)". In practice, Keycloak realm.json is structured JSON best built via serde_json::json!(); Authentik blueprints and Dex configs are small YAML shapes (~150 lines each) that hand-rolled writeln! covers cleanly. Pulling tera in for ~450 lines of stable string-building would add unjustified surface area. Zero new workspace dependencies — reuses serde_json + std::fmt::Write already in xtask/Cargo.toml.

  • Canonical IdentityModel: 9 services + 6 worker roles + 3 dev users are constants in model.rs. Renderers project from the model rather than each carrying its own service list — drift between backends is impossible because they share the same source. The 3 dev-user UUIDs are pinned to match `.claude/CLAUDE.md’s Keycloak table (00000…001 = jane.doe, 002 = bob.smith, 003 = admin); a unit test enforces this so a typo in a future model edit breaks the build instead of breaking devstack reseeds silently.

  • Two output modes via --production flag: devstack-friendly (3 dev users + callback URIs filled with http://localhost:8080/auth/callback + variants) vs production-clean (no dev users, empty redirect URIs with an inline operator hint like # operator: fill with your BFF callback URI). The same --backend X --production invocation slots into deploy IaC; no --production produces something the devstack scripts can consume verbatim.

  • Authentik blueprint covers the structural pieces: oauth2provider + application + core.group + core.user entries. Audience mapping is left as an operator merge step in inline YAML comments because Authentik handles aud via property_mapping on the linked application, which the blueprint can’t bundle into a single self-contained entry (Authentik doesn’t have Keycloak’s "service-account client" shorthand). Comment block in each provider entry documents what to wire.

  • Dex is connector-agnostic on purpose: Dex doesn’t manage users itself — it federates an upstream IdP (LDAP / OIDC / GitHub / passwordDB). The Dex render covers just the static-clients block + header comments pointing the operator at where to wire connectors: + issuer: + storage: from their existing IaC. Worker-role membership comes from the upstream’s groups claim; CRAIG’s middleware reads it via CRAIG_IDENTITY_ROLES_CLAIM_PATH. Audience enforcement is documented as the audience=<svc> query param convention since Dex doesn’t have native aud-mapper config.

  • Per-backend snapshot tests via structural assertions, not committed fixture files: the plan body said "per-backend snapshot tests pin the rendered output". insta (the canonical snapshot crate) isn’t in workspace, and committing literal fixture files invites brittle whitespace/order diffs every time a comment changes. The 30 inline structural tests (11 Keycloak + 10 Authentik + 9 Dex) pin the meaningful invariants — "all 9 service clients emitted", "each carries an audience mapper", "production mode omits dev users", "realm name carries through" — without coupling to byte-level output. If a future regression actually matters, it’ll fail a meaningful test rather than a whitespace snapshot.

  • Empty-realm + whitespace-realm guards: render::validate_realm() rejects both at the top of each renderer. Catches the --realm "" foot-gun before it produces broken IaC.

  • Live CLI smoke confirmed all 3 backends: cargo run -p xtask --quiet — identity render --backend keycloak produces 459 lines of valid Keycloak JSON; --backend authentik produces a parseable Authentik blueprint; --backend dex produces a Dex config with header docs + 11 static clients. 43 inline tests + workspace 1940/1940 green.

Step 12 deviations (2026-05-11)

Step 12 ships cargo xtask identity verify as a read-only OIDC conformance gate. New module xtask/src/cmd/identity.rs (385 lines incl. 9 inline tests). Wired into cargo xtask validate at [6c/14] as a soft check.

  • 3-variant outcome: IdentityVerifyOutcome::{AllPass, Failures(Vec<String>), Inconclusive(String)}. The third variant exists so validate.rs::check_identity_soft can downgrade transient or local-dev unreachability ("CRAIG_IDENTITY_ISSUER not set", "discovery doc unreachable: connection refused") to a SKIP / warning, while real contract failures (missing token_endpoint, empty JWKS, non-Bearer token_type) still surface as red FAIL lines. Without the distinction the pre-push gate either over-bails on offline devs or under-reports real misconfigurations.

  • PKCE worker probe deferred: the plan body listed an optional 4th check ("(if test worker creds available) performs an authorization_code flow with PKCE against a test worker principal"). That probe requires either an interactive browser callback or a headless authorization-server adapter — neither fits a single-binary CI gate. Plan E’s core contract is the service-identity (client_credentials) flow; the worker-token flow is already covered end-to-end by E2E Playwright. Successor MR can add --worker-only if a deploy-time worker-token regression slips past Playwright.

  • Probe-injection shape: run_with_probes(issuer, client_id, client_secret, discovery_probe, jwks_probe, token_probe) takes three FnOnce closures so tests drive each network step without making real HTTP calls. Mirrors the pattern check_docs::check_docs_with established earlier in xtask. Public functions fetch_discovery / fetch_jwks / request_client_credentials_token are the real-network probes; both run() (CLI) and check_identity_soft() (validate.rs) wire them in via the same client.

  • Env-var precedence with empty-string normalization: --flag → CRAIG_IDENTITY_* lookup chain plus .filter(|s| !s.is_empty()). Catches the recurring CRAIG_IDENTITY_ISSUER= shell-export-with-no-value foot-gun — empty string would otherwise pass the Option::Some check and feed an empty URL to the discovery probe.

  • Soft-check wiring is silent when issuer absent: the pre-push [6c/14] step prints SKIP (CRAIG_IDENTITY_ISSUER not set) and returns, never bails. Production deploy pipelines pass the env vars explicitly so the same cargo xtask validate invocation becomes a hard gate by virtue of the env-var presence — no separate "production-mode" flag needed.

  • Devstack smoke validates the failure path: running against the live devstack Keycloak surfaces a real JWKS unreachable failure (the issuer self-advertises host.docker.internal in the discovery doc, which the host can’t DNS-resolve). That’s the gate working as designed — production deployments with consistent intra-cluster DNS would pass.

  • Workspace deps unchanged: reuses reqwest, serde, serde_json, clap, anyhow already in xtask/Cargo.toml. No new dependency.

Step 11 deviations (2026-05-11)

Step 11 adds caller-service + acting-worker columns to services/craig-security’s `audit_log and populates them from the wildcard subscriber. The migration is the destructive pre-1.0 variant the plan body called for: ALTER TABLE audit_log ADD COLUMN actor_service TEXT NOT NULL DEFAULT 'unknown', ADD COLUMN actor_user_sub UUID. Plus matching indexes (idx_audit_log_actor_service, idx_audit_log_actor_user_sub) so the new filters scale.

  • Insert path is param-driven, not Claims-driven: the plan body’s reference snippet showed insert_audit_entry reading claims.service_id() + claims.actor.as_ref().map(\|a\| &a.sub) directly. In practice today’s only insert caller is the wildcard subscriber handle_inbound_event, which gets an EventEnvelope — no Claims in scope. Pushing &Claims into the store-level fn would change its API for one caller that doesn’t have one. InsertAuditEntryParams instead gains actor_service: String + actor_user_sub: Option<Uuid>; the subscriber fills them from the envelope; future direct callers fill from claims.service_id().unwrap_or("unknown").to_string() + claims.actor.as_ref().and_then(|a| Uuid::parse_str(&a.sub).ok()). Same end shape, store stays decoupled from Claims.

  • actor_serviceEventEnvelope.source_service: the envelope’s source_service is exactly the caller-service signal the plan body asks for. For service-to-service flows where craig-cases is emitting on behalf of a worker authenticated via craig-web, source_service is still craig-cases — the outermost service producing the audit-relevant side effect, not the outer BFF. That matches the audit-attribution intent: the row reflects which service did the work, not which BFF was in the call chain.

  • actor_user_sub derives from the existing payload best-effort extraction: factored into event_parsing::extract_actor_user_sub(envelope) → Option<uuid::Uuid> so the priority order (created_byapproved_byuser_idarchived_by) is testable in isolation. Returns Some(uuid) when the first non-null attribution field parses as UUID; None for system identifiers ("system"), legacy preferred_username-shaped values ("jane.doe" — should not appear post Plan A Step 4 but guarded), and events without any attribution field. 7 inline unit tests pin each branch.

  • AuditQuery + list/count store params gain actor_service: Option<String> + actor_user_sub: Option<Uuid> filters: pure additions (no rename). The validated sort whitelist also accepts the two new columns. Lets the BFF pivot the audit view on caller-service or acting-worker without a separate endpoint.

  • 2 devstack integration tests (audit_rows_carry_actor_service_attribution, audit_filter_by_actor_service) verify the round-trip: every audit row surfaces a non-empty actor_service after reseed, and the filter narrows the result set to a single source service. 1888/1888 workspace tests pass post-reseed (up from 1879 — 9 new tests).

  • Migration is timestamp 20260511100000_audit_log_actor_columns.sql: pre-1.0 destructive, no backfill machinery (no production data to preserve). New rows on a fresh reseed populate from the subscriber.

Step 10 deviations (2026-05-11)

Step 10 ships the bearer flip across services/craig-intake’s 4 outbound paths (partner_auth, signer_auth, cases_forwarder, attachments) — all now use Step 7’s `CraigClientExt::with_service_identity instead of manually building bearer_auth(&token.current().await?). Cosmetic refactor in spirit, but it brings intake’s outbound surface in line with the Step 7 helper contract (1 way to authenticate outbound, not 2). 1879/1879 workspace tests pass.

  • No X-Craig-Actor attached: the plan body called for partner-as-actor attribution on /partners/verify and /signer-keys/by-kid calls. The actor JWT contract Step 5 designed targets workers (WorkerActor { sub, preferred_username, roles }); encoding partners would conflate principal types. Partner identity already flows in the request body (partner_id in the cases-forwarder report payload; the raw API key in the partner-verify body) and downstream audit rows read it from there — no signature attribution needed at the JWT level. If a future audit requirement needs partner-bound JWTs, extending the actor contract to Principal { Worker | Partner } can be done as a successor.

  • partner_auth.verify can’t have a partner actor: the verify call IS the partner-identity lookup — at call time, intake doesn’t yet know whose API key it’s holding. Service-only bearer is the only honest option.

  • signer_auth.lookup similarly service-only: looking up a signer key by kid runs ahead of JWS verification; the partner association is downstream.

  • CasesForwarderSink::service_token() accessor added: gives attachments.rs direct access to the OidcServiceToken so it can pass it to with_service_identity. The legacy bearer_token() → String helper (Step 4-era) is retired — there’s now a single way to send the bearer (through the trait).

Step 9b deviations (2026-05-11)

Step 9b ships the actual BFF flip: ApiClient methods + the shared helpers (fetch_page, fetch_page_or_empty, batch_lookup) now take &SessionUser instead of token: &str. Outbound calls send craig-web’s client_credentials token as Authorization: Bearer and attach a freshly-minted X-Craig-Actor JWT identifying the acting worker. Backend services (Step 6) verify the actor JWT and lift the worker identity into Claims::actor.

  • Surface was ~91 call sites, not the 25 the plan body called out: the plan body cited 6 bearer_auth sites in api_client.rs. In practice those 6 methods are called by ~91 sites in services/craig-web/src/routes/ (some via fetch_page / fetch_page_or_empty / batch_lookup helpers). 29 route files updated. Mechanical regex pass + manual cleanup of let token = &user.access_token; bindings that became unused.

  • &SessionUser parameter, not &Claims or &WebSession: SessionUser is craig-web’s projected session shape (already used everywhere as the Extension-injected handler param). It has sub, username, roles, access_token — exactly what WorkerActor needs plus the bearer for the legacy fallback. Using Claims would require synthesizing one from session data; using WebSession would shift the handler-side signature contract.

  • ApiClient::apply_identity mints WorkerActor directly, doesn’t go through CraigClientExt::with_craig_identity: the Step 7 extension trait takes Option<&Claims> and projects via WorkerActor::from_claims. Building a synthetic Claims from a SessionUser just to feed it back into a projection is needless. The ApiClient internal helper builds WorkerActor from SessionUser directly + calls issuer.mint(&worker) + attaches the header. Same end result, less indirection.

  • Legacy bearer-forward fallback kept: when service_token or actor_issuer is None (e.g., a devstack profile without provisioning), apply_identity forwards user.access_token as the bearer. Lets the flip land safely even if a deployer hasn’t provisioned the new principals yet.

  • Devstack reload smoke verified: all 9 services log peer-JWKS-wired + per-service issuer-wired at boot; craig-web /healthz returns 200; backend services accept BFF calls with the new identity shape.

  • Authz had to scope on the acting worker, not the calling service: a P0 issue surfaced by E2E: with X-Craig-Actor flowing, backends saw claims.is_service() == true (outer caller is craig-web) and admitted via the service-caller rule with scope=all — caseworkers saw all cases instead of their own, RBAC boundaries broke. Two craig-authz fixes: (1) build_check_input / build_scope_input now read claims.acting_worker() for the policy’s identity fields (sub, preferred_username, roles) and only treat the JDM is_service flag as true when no actor was lifted; (2) claims_sub_uuid reads the acting worker’s sub so ListScope::AssignedWorker(sub) filters by the worker’s UUID, not the caller service’s. Service-caller-only paths (peer bulk-fetch, cold-boot fallback) still admit because the outer Rust-level claims.is_service() method is unchanged and craig-rules' allow_bootstrap_fallback still admits services. End-to-end verified: 203/203 E2E tests pass with the BFF flip active.

Step 9a deviations (2026-05-11)

Step 9 split into 9a (provisioning + craig-web bootstrap wiring; this MR) and 9b (ApiClient flip + ~25 caller-site updates; subsequent MR). Step 9a’s purpose: activate Step 6’s actor-JWT verifier on every backend service by provisioning the deployment-wide peer-JWKS map + per-service signing keys, plus extend craig-web’s AppState so the ApiClient methods can borrow the new primitives once Step 9b flips them.

  • Devstack provisioning of actor keys pulled into Step 9, not Step 14: plan body Step 14 originally owned devstack key provisioning. But Step 6’s verifier middleware is fail-closed when an X-Craig-Actor header arrives without a populated registry — so Step 9b’s flip can’t safely land until peer-JWKS provisioning is in place. Pulling provisioning into Step 9a removes the deploy-window where keys are missing while callers are sending the header. 9 ES256 keypairs generated once + committed at devstack/devstack-actor-keys.env (devstack-only, with explicit production-warning header). docker-compose.yml env_file-references the file for all 9 services. Step 14 remains for the production-grade provisioning story (file-mounted secrets, IaC integration, rotation).

  • craig-web’s WebSettings gains client_id + client_secret: craig-web doesn’t use the standard bootstrap() (it has its own settings shape since it’s not a stateful backend), so the Step 8 env-var contract had to be re-added inside services/craig-web/src/config.rs. Optional fields — None falls back to forwarding the worker bearer (legacy).

  • ApiClient::with_outbound_identity() builder, not constructor: keeps the existing ApiClient::new(http) call sites untouched in this MR. AppState chains .with_outbound_identity(svc, issuer) at boot. Step 9b will use these fields inside the 6 method bodies.

  • ApiClient methods + ~25 caller sites deferred to Step 9b: keeping Step 9a tightly scoped to "wire the primitives into place" lets it land safely. Step 9b is the actual semantic flip: method signatures change from token: &str to session: &WebSession, internals use with_craig_identity, all caller sites in services/craig-web/src/routes/ update.

  • Devstack reload smoke verified: all 9 services log actor-JWT verifier wired with peer JWKS count=9 at boot + their per-service actor-token issuer wired line. jane.doe LIST cases returns 7 (caseworker + supervisor / Georgia realm-flat) — unchanged from pre-Step-9a behavior.

Step 8 deviations (2026-05-11)

Step 8 ships bootstrap wiring: new craig-auth env-loader helpers (load_signing_keypair_from_env, load_peer_jwks_from_env), BootstrapResult gains service_token: Option<OidcServiceToken> + actor_issuer: Option<ActorTokenIssuer>, and AuthLayer is wired with the deployment-wide peer-JWKS registry so Step 6’s actor verification activates as soon as keys are provisioned. All 6 standard services consume the bootstrap-provided primitives (deletes the duplicate OidcServiceToken construction Step 4 placed in each main.rs); craig-rules discards both fields with _ patterns. 5 inline unit tests cover the env loader paths.

  • No ClientExt::craig_internal() pre-bundled reqwest::Client wrapper: plan body’s last sentence sketched a Client extension that returns a builder pre-configured with both the token source + actor issuer. Step 7 already ships per-method extension helpers on RequestBuilder (with_service_identity, with_actor, with_craig_identity), which compose with arbitrary URL/method combinations. Adding a second pre-bundled wrapper at the Client level would either duplicate that surface or hide the explicit composition that makes the Step 9/10 flips legible. Skipped without loss of capability.

  • Option<…​> BootstrapResult fields: services boot without env-provisioned principals (service_token = None, actor_issuer = None) so the rollout doesn’t gate on devstack/CI provisioning. Outbound callers (Step 9/10) handle the None case as a config error at the call site rather than at boot — keeps Step 8 isolated to wiring without forcing operator action.

  • Per-service keypair env vars are inline JWK (<prefix>SIGNING_JWK) + explicit kid (<prefix>SIGNING_KID): no file-path variant yet. Production deployments will want a path / secret-mount path; deferred to a future iteration alongside the production secrets story. Pre-1.0 devstack uses inline.

  • Peer JWKS env is a single deployment-wide var CRAIG_PEER_JWKS_JSON: holds a JSON array of {iss, kid, jwk} entries. Same value for every CRAIG service. Allows multiple entries per iss for rotation windows. Devstack provisioning is deferred to Step 14.

  • AuthLayer::with_actor_registry wired at bootstrap when the peer map is non-empty: when the map is empty (default until Step 14 provisions it), middleware fail-closes any inbound X-Craig-Actor per Step 6. When the map is populated, peer-service actor JWTs verify and lift into Claims::actor.

  • craig-rules discards service_token + actor_issuer with _ patterns: craig-rules is the rules-engine source of truth; it makes no outbound CRAIG service calls and never mints actor JWTs. Other 5 standard services consume the new fields.

Step 7 deviations (2026-05-11)

Step 7 ships crates/craig-auth/src/client_ext.rs: outbound reqwest::RequestBuilder extension methods that attach the calling service’s client_credentials bearer (with_service_identity) and optionally mint + attach an actor JWT (with_actor). 6 inline unit tests use a localhost fake OIDC token endpoint for the bearer-attachment path; the actor-attachment path round-trips through Step 6’s verifier to prove the minted header is well-formed.

  • with_service_identity is async, not sync: plan body D6 showed current_blocking() returning a string synchronously. Step 4 ships OidcServiceToken::current as async fn (cache hit returns immediately; cache miss awaits a reqwest POST). A blocking wrapper would deadlock when invoked inside an async runtime context. The trait method is async fn using Rust 2024’s native async fn in trait. Returns Result<Self, ServiceTokenError> so callers can .await? it inline.

  • with_actor takes an explicit &ActorTokenIssuer parameter (not a global): plan body D6 referenced an ACTOR_ISSUER constant. Globals tangle dependency injection. The trait takes the issuer as a parameter; Step 8 will hold the per-service ActorTokenIssuer in BootstrapResult so handlers can borrow it from Extension.

  • Combined helper with_craig_identity: added a third trait method that runs both attachments in one await + result. Common BFF flip pattern from Step 9 looks much cleaner with the combined form than with chained .await? and ? interleaved inside a builder chain. Tests cover both ergonomic surfaces.

  • WorkerActor::from_claims lives on client_ext: the projection method is defined as an inherent impl on WorkerActor (in this module’s file) so crate::actor_token doesn’t gain a Claims dependency. Test covers that the projection strips extras (email, aud, azp) that the actor JWT contract doesn’t carry.

Step 6 deviations (2026-05-11)

Step 6 ships crates/craig-auth/src/actor_verifier.rs (the verifier primitive) and wires it into crates/craig-auth/src/middleware.rs::auth_middleware. 4 middleware-level integration tests + 8 inline verifier tests cover round-trip, signature failure, tampered payload, smuggled long TTL, wrong audience, malformed JWT, worker-token ignore path, and unconfigured-registry fail-closed.

  • Per-service JWKS via ActorJwksRegistry trait, not a single "craig-signing" JWKS: plan body D5 / D3 framed verification as "validate the actor JWT against craig-signing’s JWKS" as if craig-signing were a service publishing keys. It’s a library crate, not a service. Each CRAIG service signs with its own keypair (Step 5 + Step 8); the verifier needs to look up the right key by (iss, kid). Step 6 introduces ActorJwksRegistry (trait + StaticActorJwksRegistry HashMap-backed default impl). Step 8 will populate the registry per service from peer-service JWKS endpoints; Step 6 ships the verification primitive + abstractions.

  • Workers carrying X-Craig-Actor are log+ignored, not rejected: plan body D5 said "Reject if actor JWT validation fails — never silently drop." For invalid actor JWTs on service callers, Step 6 honors that contract (401). But for the orthogonal case of a worker JWT (no service:* role) carrying X-Craig-Actor, that header is semantically meaningless — workers can’t assert on-behalf-of identity — and rejecting would block worker traffic if a stray middleware ever attached the header. The middleware logs the anomaly at debug and ignores the header for worker callers. This is fail-open for a header the receiver doesn’t trust anyway; the actor identity is never lifted into Claims::actor, so authorization logic continues to treat the request as worker-only.

  • Header present + registry unconfigured → 401, not silent skip: between Step 6 landing and Step 8 wiring the registry into each service’s bootstrap, no service has an actor registry. The plan body’s "never silently drop" maps to: if a service-caller sends X-Craig-Actor but the receiver has no registry, reject 401. Callers won’t start sending the header until Step 7 ships outbound helpers + Step 9/10 flip the callers, so this fail-closed posture is invisible during the deploy gap.

  • TTL ceiling + iat-in-future checks in the verifier, not just jsonwebtoken: jsonwebtoken’s Validation enforces exp > now but doesn’t cap (exp - iat). A malicious peer could mint a 24h-lived actor JWT and replay it. The verifier explicitly caps TTL at ACTOR_TOKEN_TTL_SECONDS + 30s skew tolerance and rejects iat > now + 30s. Both have dedicated ActorVerifyError variants for clearer operator log lines.

Step 5 deviations (2026-05-11)

Step 5 ships the ActorTokenIssuer module: short-lived ES256-signed compact JWTs that carry worker-on-behalf-of identity through service-to-service hops, with a fixed aud = "craig-internal-actor" audience namespace and a 10-minute TTL. 7 inline unit tests cover header shape, payload claims, signature verification, tampered-payload rejection, and wrong-key rejection.

  • Module placed in crates/craig-auth/src/actor_token.rs, not craig-signing: the plan body called for the module to live in crates/craig-signing. craig-signing’s mandate is the cross-language canonicalize_json + payload-hash contract that the Rust / TypeScript / Python / browser intake SDKs all consume (single source of truth for the partner JWS signing contract). Adding server-only ES256 JWT-minting would have grown craig-signing’s API surface (read by SDK consumers in three languages) and pulled in p256 + base64ct + chrono deps. craig-auth is server-only, already houses related primitives (Claims, OidcServiceToken, OidcDiscovery, JwksProvider), and is the natural home for the verifier (Step 6) too.

  • No "existing keypair" to reuse: the plan body said "Reuses each craig-* service’s existing canopy-signing-style keypair (already used for ADR-018 partner JWS — same key, distinct aud namespace)". CRAIG services do not have per-service signing keys today — partner-JWS infrastructure (services/craig-{intake,security} for verify, in-browser craig-sign.js + SDK clients for sign) handles partner-supplied keys. CRAIG services themselves don’t sign anything yet. The ActorTokenIssuer constructor takes a p256::ecdsa::SigningKey directly; Step 8 (bootstrap wiring) will provision per-service keypairs and load them at boot from a CRAIG_<SVC>__SIGNING_JWK env var.

  • WorkerActor projection instead of Claims: the issuer accepts a small WorkerActor { sub, preferred_username, roles } struct rather than borrowing a full Claims instance. This decouples craig-auth’s actor-token module from upstream Claims evolution (e.g., the deferred Step 3b configurable roles-claim path) and keeps the minted payload bounded to vetted fields — an inbound bearer’s raw aud array can’t accidentally leak into the actor JWT.

Step 4 deviations (2026-05-11)

Step 4 ships the OidcServiceToken module and retires the 6 in-service Keycloak ROPC bootstraps. New per-service confidential OIDC clients (service:craig-<svc> realm roles) in the devstack realm; per-service CLIENT_ID / CLIENT_SECRET env vars in docker-compose.yml. 98 rulesets gained an i_service JDM input + service-caller admit rule. Claims::require_* OR-in is_service() so role-gated handlers admit service callers without rewriting them.

  • Cold-boot chicken-and-egg fallback extended to service callers: pre-Plan-E the seed authenticated as the admin ROPC user, so services/craig-rules/src/api.rs::rules_authz_or_admin_fallback admitted admin to escape the empty-cache fail-closed window at boot. Post-Plan-E the peer-service bulk-fetch (each service’s ZenAuthzEngine::boot calling RulesClient::list_by_prefix) is now a service principal with no admin role. The existing fallback rejected it, the warmup retry budget exhausted before the seed completed inserting policies, and every authz LIST scope downstream came back Denied. Step 4 extracts a pure allow_bootstrap_fallback(&Claims) predicate and admits claims.is_service() alongside the admin role. Real per-user authz still applies once the cache is populated. 4 inline unit tests cover the predicate. Verified end-to-end on a cargo xtask dev reseed cold-boot: jane.doe (supervisor) sees 7 cases via realm-flat scope, bob.smith (caseworker) sees 2 via assigned-worker scope, admin sees 7.

Step 3 deviations (2026-05-10)

Step 3 ships the Claims API extensions and ADR-028 acceptance. 9 new unit tests in crates/craig-auth/src/claims.rs (42 total, was 33).

  • Configurable CRAIG_IDENTITY_ROLES_CLAIM_PATH deferred to Step 3b: the plan body called for a configurable JSON-path lookup so non-Keycloak IdPs (Okta groups, Azure AD roles, etc.) can emit roles at their own claim path. Implementing this means changing Claims to retain the raw JSON value (or use a serde_json::Value accessor) instead of the typed realm_access.roles field. The refactor is invasive and orthogonal to the service-caller predicates this step shipped. Step 4 (OidcServiceToken) can land before Step 3b. The CRAIG_IDENTITY_SERVICE_ROLE_PREFIX env var (used by the service-role check) ships in Step 3 as planned.

  • ADR-028 accepted in Step 3, not Step 2: the plan body Status row for Step 1 said acceptance lands in Step 2. Step 2 shipped OIDC discovery convergence (a runtime fix) without flipping ADR-028’s status. Step 3 ships the Claims extensions that the ADR’s contract defines — accepting it now is more accurate. ADR-028 Status updated 2026-05-10; nav.adoc entry updated to match.

  • #![forbid(unsafe_code)] precludes in-crate env-var override tests: the env-var prefix override behavior would require std::env::set_var which is unsafe in Rust 2024 edition. The crate forbids unsafe at the lib level. Coverage moved out-of-crate (deployment / integration tests / manual verification). The default-prefix path has full coverage; the override path is exercised by env config.

Step 2 deviations (2026-05-10)

Step 2 ships OIDC discovery convergence in crates/craig-auth (new OidcDiscovery module + JwksProvider rewired to use it) and services/craig-web/src/auth.rs (login/callback/logout flows resolve endpoints via the discovery doc). Five new unit tests cover the discovery client (fetch, TTL caching, on-expiry refresh, split-DNS host rewrite x2). All 1831 workspace tests pass; devstack reload verified end-to-end.

  • Split-DNS host rewriter: the plan body assumed the discovery doc’s endpoints could be consumed verbatim. In practice, Keycloak emits the public issuer URL (http://host.docker.internal:43441/realms/craig/…​;) but internal services need to reach it at keycloak:8080. Step 2 adds a rewrite_endpoint_host helper that swaps the host portion of jwks_uri and token_endpoint to the internal fetch_url when issuer != fetch_url. authorization_endpoint and end_session_endpoint keep the public URL since those land in HTTP redirects to the user-agent. This is the pattern every OIDC client library handles for split-DNS deployments.

  • Rust-field rename split to Step 2b: the plan body called for renaming keycloak_issueroidc_issuer with a backwards-compat env var alias for one release. Step 2 ships the architectural fix only; Step 2b lands the rename as a separate, mechanically-large but content-trivial MR. Per pre-1.0 policy (no production data to migrate, no external consumers locked in), Step 2b is destructive: no alias, no shim. keycloak_issueroidc_issuer, keycloak_urloidc_internal_url, CRAIG_<SVC>KEYCLOAK_ISSUERCRAIG_<SVC>OIDC_ISSUER, CRAIG_<SVC>KEYCLOAK_URLCRAIG_<SVC>OIDC_INTERNAL_URL across all 25 affected files.

  • Stricter JWT validation already in place: the plan body called for "bring craig’s stricter JWT validation along (aud/azp/scope/typ + nbf checks)". Those were already implemented pre-Plan-E in crates/craig-auth/src/{claims.rs,jwks.rs} per ADR-021. No additional code needed in Step 2.

After this plan lands

  • Per-service azp distinguishes which craig service called any given endpoint — observable via audit_log actor_service column.

  • Audit log captures both the calling service AND the acting worker, so reviewers can answer "did the worker invoke X directly or via the BFF?" with a per-row read.

  • KeycloakServiceToken retired in favor of IdP-neutral OidcServiceToken + OIDC-discovery + client_credentials — fully aligns service-to-service auth with ADR-026’s IdP-neutrality stance.

  • Operators of any compliant OIDC backend (Keycloak, Authentik, Dex, Okta, Entra, ForgeRock, custom) can deploy craig without backend-specific code in craig itself.

  • xtask identity verify provides a deployer-facing conformance gate — operators run it post-provisioning to confirm their backend matches craig’s contract.

  • xtask identity render provides reference IaC for the three most common open-source backends; non-supported backends have a clear contract spec to fulfill manually.

  • Service-token lifetime decoupled from worker session — long-running drainer publishes + scheduled jobs no longer depend on a worker’s 8-hour session.

Edit this page · latest