Plan: canopy-identity contract + service-identity migration (Issue #424, ADR-019)
On this page
Status
| Step | Description | Status |
|---|---|---|
1 |
ADR-019 + this plan land as a standalone docs MR ( |
Not started |
2 |
|
Not started |
3 |
|
Not started |
4 |
|
Not started |
5 |
Auth middleware: actor extraction. Update |
Not started |
6 |
Outbound helpers in * Internal services that don’t need user identity call |
Not started |
7 |
Bootstrap wiring. |
Not started |
8 |
|
Not started |
9 |
* Backends without a render adapter (Okta, Entra, ForgeRock, custom) require operator-side configuration in their existing tooling. The contract definition + |
Not started |
10 |
|
Not started |
11 |
|
Not started |
12 |
canopy-eligibility orchestrator switch. |
Not started |
13 |
canopy-web service-identity wiring. |
Not started |
14 |
Cutover: enforce service-identity on internal-only endpoints. Per ADR-019 hard-cutover migration: * Each removed role-gate has a corresponding |
Done (2026-05-10) — slice 1 (MR !233, canopy-rules cutover + program-service rules-client outbound refactor) merged. Slice 2 (program-service |
15 |
Audit log enrichment. ADR-014 audit-log writers ( |
Not started |
16 |
Tests: * Per-service |
Not started |
17 |
Docs. * Security — new "canopy-identity contract" section describing the post-ADR-019 model.
* Architecture — request-flow narrative update (today says "worker JWT travels end-to-end"; updates to "worker JWT terminates at BFF / determine entry; service tokens travel onward, X-Canopy-Actor carries worker identity for audit + RBAC").
* |
Not started |
Issue: #424
Branches: docs/adr-019-service-identity (Step 1 — MR 1), feat/e2-service-identity-foundations (Steps 2-11 — MR 2), feat/e2-service-identity-cutover (Steps 12-17 — MR 3)
Labels: type::refactor, priority::medium, service::security, service::shared-crates, program::infrastructure, compliance::pub-1075, workflow::ready
Context
E0.5 (#429, MR !223) added per-endpoint role gates to canopy-rules to plug the reviewer’s "any worker JWT can read every ruleset" finding. Per-endpoint role gates are a tactical close — the architectural endpoint is service identity, where internal services don’t accept worker JWTs at all.
ADR-019 establishes canopy-identity as a contract (env vars + OIDC discovery requirements + token-shape requirements + conformance test). The contract is fulfilled by an OIDC issuer the operator chooses — Keycloak by default in the dev stack; Dex, Authentik, Okta, Entra, ForgeRock, or custom in production. Canopy ships contract + conformance test + reference IaC templates + dev provisioning. Production identity-backend lifecycle is the deployer’s responsibility.
This plan implements the contract on the canopy code side (Claims API, ServiceTokenSource, ActorTokenIssuer, middleware, outbound helpers, bootstrap wiring) and on the dev/CI side (xtask verify/render/dev-provision). Production-side provisioning is not in scope for canopy code — operators use their existing IaC.
Code references
-
services/canopy-eligibility/src/orchestrator.rs:84,139,186,393— every.bearer_auth(auth_token)is a JWT pass-through call site. -
services/canopy-eligibility/src/api/handlers.rs:24-28—extract_bearer_tokenextracts the worker token from the inbound request to forward downstream. -
services/canopy-rules/src/api/mod.rs— per-endpointrequire_any_rolecalls (post-#429). -
crates/canopy-auth/src/claims.rs—ClaimsAPI gets the newis_service()/service_id()/actorextensions and the configurable roles claim path. -
crates/canopy-auth/src/middleware.rs— auth middleware gains actor extraction. -
crates/canopy-auth/src/jwks.rs— already uses OIDC discovery (per #422); reused as-is for canopy-identity JWKS validation. -
crates/canopy-signing/src/— existing ES256 signing infra; actor JWT signing is a small extension. -
devstack/keycloak/canopy-realm.json— current realm with 2 clients; 13 service-account clients added in Step 11. -
ADR-014 — audit log row shape (this plan adds
actor_serviceandactor_user_subcolumns). -
ADR-017 — where
CANOPY_<SVC>_CLIENT_SECRETlives. -
CRAIG ADR-011 / 021 / 026 — worker-auth patterns canopy adopts as-is.
Scope
In scope:
-
canopy-identity contract definition (env vars, OIDC discovery requirements, token shape, claim path conventions).
-
ClaimsAPI extensions (is_service,service_id,require_service_caller,actor, configurableroles()lookup). -
ServiceTokenSource(canopy-auth) — OAuth2 client_credentials wrapper with refresh. -
ActorTokenIssuer(canopy-signing) — service-signed actor JWTs. -
Auth middleware actor-header extraction + canopy-signing JWKS validation path.
-
Outbound helpers (
with_service_identity,with_actor). -
canopy-eligibility orchestrator + canopy-web flip from JWT pass-through to service identity.
-
Hard-cutover enforcement on internal-only endpoints; per-endpoint role-gate removal.
-
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
canopy-realm.jsonextension +secrets/dev.yamlentries. -
Documentation of the contract for deployers (
idp-integration.adoc).
Out of scope:
-
Production identity-backend lifecycle tooling. Canopy does not own provisioning, secret rotation, or admin operations against deployer-owned IAM backends. Deployers use their existing IaC (Terraform, Helm, Ansible, Vault, gitops blueprints, Operator CRs, admin consoles — operator’s choice).
-
A canopy-identity service container. canopy-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,
/determineworker entry).
Design
The canopy-identity contract (operator-facing)
| Variable | Meaning |
|---|---|
|
OIDC issuer URL. |
|
Optional in-cluster network locator for the issuer. |
|
Audience for service tokens. Default |
|
JSON path to roles array. Default |
|
Service-role marker. Default |
|
Per-service OAuth2 client_id. |
|
Per-service OAuth2 client_secret. |
Required OIDC discovery: authorization_endpoint, token_endpoint, jwks_uri. Optional: end_session_endpoint.
Required token shape: see ADR-019 §"Required token shape".
Wire shape (post-cutover)
Worker → canopy-web (worker JWT issued by canopy-identity):
POST /cases/{id}/actions/file-appeal HTTP/1.1
Authorization: Bearer eyJ... (worker JWT, iss=canopy-identity-issuer, aud=canopy-ui)
canopy-web → canopy-eligibility (service token issued by canopy-identity + actor JWT signed by canopy-web):
POST /v1/eligibility/determine HTTP/1.1
Authorization: Bearer eyJ... (service token, iss=canopy-identity-issuer, azp=canopy-web, aud=canopy-internal-service, roles=[service:canopy-web])
X-Canopy-Actor: eyJ... (canopy-web-signed actor JWT, sub=jane.doe.uuid, aud=canopy-internal-actor, exp=now+10m)
canopy-eligibility → canopy-snap (service token + new actor JWT signed by canopy-eligibility this hop):
POST /v1/determine HTTP/1.1
Authorization: Bearer eyJ... (service token, azp=canopy-eligibility)
X-Canopy-Actor: eyJ... (canopy-eligibility-signed actor JWT, sub=jane.doe.uuid)
canopy-snap audit row (post-Step 15):
INSERT INTO fti_audit_log (
..., actor_service, actor_user_sub, ...
) VALUES (
..., 'canopy-eligibility', 'jane.doe.uuid', ...
);
Trust topology
-
canopy-identity issuer (whatever the operator deploys) publishes JWKS for worker + service token validation.
-
canopy-signing publishes its own JWKS for actor JWT validation. Distinct from canopy-identity JWKS.
-
Each canopy-* service trusts:
-
canopy-identity JWKS for bearer-token validation (workers AND services, same JWKS, different
aud). -
canopy-signing JWKS for actor-JWT validation (X-Canopy-Actor header).
-
-
Cross-stack: each jurisdiction’s stack uses its own canopy-identity issuer + canopy-signing keys. Cross-stack tokens fail signature verification.
Files Touched
| File | Change |
|---|---|
|
New ADR (MR 1) |
|
canopy-identity contract documented for deployers; per-backend setup notes; xtask verify/render/dev-provision usage |
|
Add |
|
Actor-header extraction + canopy-signing JWKS validation path |
|
New module — |
|
New trait — |
|
Add |
|
New module — |
|
Construct |
|
New module — |
|
Reference Keycloak realm template |
|
Reference Authentik blueprint template |
|
Reference Dex config template |
|
Replace |
|
Drop |
|
Same flip as orchestrator |
|
Replace |
|
Remove per-endpoint role gates added in #429; replace with |
|
Audit-log enrichment |
|
3 forward-only migrations adding |
|
13 new |
|
Add 13 service-account clients (placeholder secrets) + audience mappers + |
New "canopy-identity contract" section |
|
Request-flow narrative update |
|
|
|
Verification
-
cargo nextest run -p canopy-auth— unit tests onClaims::is_service,service_id, configurable roles path,ServiceTokenSourcemock-server smoke. -
cargo nextest run -p canopy-signing— unit tests onActorTokenIssuer::mintround-trip + signature verification against own JWKS entry. -
Devstack-gated per-service
service_token_acquisition_smoke— all 13 services successfully exchange client_credentials for a token at startup. -
Devstack-gated
actor_propagation_regression— worker → canopy-web → canopy-eligibility → canopy-snap audit row carries correctactor_service+actor_user_sub. -
cargo xtask identity verify --issuer <devstack>runs in CI as a regression check; passes for the devstack Keycloak realm. -
cargo xtask identity render --backend keycloakproduces a validrealm.json(compared against thedevstack/keycloak/canopy-realm.jsonground truth in a test). -
Every removed role-gate has a corresponding
endpoint_requires_service_caller_rejects_worker_jwttest pinning the new shape. -
cargo xtask validate— full battery green at each MR boundary. -
Manual smoke: revoke a service’s Keycloak client secret in dev (
kcadm.sh … reset-secret), restart the service — startup proceeds (fails at first refresh attempt), service token caching means inbound calls work for up to 1h after revocation, then fail closed with a clear log line.
Documentation Updates
-
docs/modules/ROOT/pages/adrs/adr-019-service-identity-and-on-behalf-of.adoc— new ADR (MR 1) -
Security — "canopy-identity contract" section
-
Architecture — request-flow narrative update
-
docs/modules/ROOT/pages/idp-integration.adoc— canopy-identity contract for deployers; per-backend setup notes; xtask verify/render/dev-provision usage -
CHANGELOG.adoc—=== Changed(impl) +=== Security(cutover); separate entries -
Plan archive: move to
plans/archive/post-MR-3 merge