---
status: accepted
date: 2026-06-10
---

# Two-Plane Authentication Model — Tenant Plane (Per-Hop JWT) vs Platform Plane

**ID**: `cpt-cf-adr-two-plane-auth`

## Table of Contents

<!-- generated by `cypilot toc` -->

## Context and Problem Statement

OoP gears make two kinds of authenticated calls across process boundaries: **tenant-scoped** calls that carry a user
(or service) identity, and **platform/system** calls (DirectoryService registration, GTS registration, heartbeats) that
carry no user context. This ADR decides how identity is carried and trusted across hops.

Two alternative approaches are weighed against the chosen model:

* **Unsigned envelope propagation ("auth at edge only").** The gateway validates the JWT once and propagates a
  pre-parsed `SecurityContext` as an **unsigned** `x-secctx-bin` header; OoP gears do **not** re-validate. Its integrity
  depends entirely on transport trust: an unsigned blob means any process that can deliver bytes can forge
  `subject_id` / `tenant` / scopes, so it is only safe if the peer is cryptographically authenticated *before* the
  envelope is decoded (i.e. mTLS everywhere) — which is not yet built, so it cannot be used securely today.
* **mTLS as the day-one trust root.** Making mTLS the prerequisite for every multi-process profile secures the
  envelope, but ties the very first OoP deployment to a finished CA / enrollment story, which delays everything.

The platform-authn and zero-trust designs both separate **tenant identity** (the user) from **workload/platform
identity** (the calling gear), and both adopt **per-hop bearer-token re-validation** rather than an unsigned propagated
envelope. This ADR adopts that two-plane model.

## Decision Drivers

* **Defense in depth** — security must not rest on a single layer (transport trust) being correctly deployed everywhere.
* **Ship the first OoP gear without PKI** — the first phase must not block on a finished CA.
* **K8s-first** — prefer K8s-native primitives now (SA tokens, cert-manager later) over bespoke PKI.
* **No throwaway work** — whatever we build first must be a backend of the eventual abstraction, not a dead end.
* **Alignment** — converge with the zero-trust and platform-authn designs rather than diverge.

## Considered Options

* **Option A**: Auth-at-edge only — the gateway validates the JWT once and propagates an unsigned `x-secctx-bin`
  envelope; OoP gears do **not** re-validate, with mTLS required as the day-one trust root.
* **Option B**: Two planes — re-validate the JWT per hop (tenant plane) and use a distinct, tenant-less
  `PlatformSecurityContext` for infra calls (platform plane), with SA tokens first and mTLS+SPIFFE next.
* **Option C**: HMAC-sign `x-secctx-bin` with a shared platform secret (keep the envelope, add integrity).

## Decision Outcome

Chosen option: **"Option B — two planes."** A request carries exactly **one** plane, never both.

### Which plane? — the criterion is *tenant-scoped vs non-tenant-scoped*, not *user vs system*

The deciding question is **"does this operation act within a tenant?"**, not "is there a human behind it":

* **Tenant-scoped** operations use the **tenant plane** and the regular `SecurityContext` + AuthZ — *even when initiated
  by a system/root actor with no human user*. A background or service-to-service job that posts an event for a tenant,
  writes tenant data, etc. carries a tenant `SecurityContext` (obtained from a user JWT or an **S2S client-credentials**
  JWT) and is authorized normally. The **root tenant runs the exact same AuthZ flow** as any other tenant — we treat all
  tenants the same, which removes the "was this platform/root or was this tenant?" ambiguity. Consumers like the Event
  Broker do **not** distinguish whether a tenant-scoped event arrived under a root S2S token or a regular tenant token.
* **Non-tenant-scoped platform** operations use the **platform plane** and `PlatformSecurityContext`, with **no AuthZ**.
  Example: registering a *global* GTS type in the Types Registry (a workspace-wide operation that pre-dates any tenant).

So "system" is not a synonym for "platform plane": a system actor doing tenant work is on the tenant plane. Only
genuinely non-tenant (platform-level) work is on the platform plane.

### Tenant plane (tenant-scoped flows — user *or* system)

* The caller forwards `Authorization: Bearer <jwt>` **as-is** (a user JWT, or an S2S client-credentials JWT for
  system-initiated tenant-scoped work).
* Every hop **re-validates** the token via `AuthNResolverClient::authenticate()` and reconstructs `SecurityContext` from
  the validated claims (~0.5 ms with cached JWKS). There is no fast path that skips signature verification in the first
  phase.
* **`x-secctx-bin` is dropped from the HTTP propagation contract.** The JWT is self-authenticating; the IdP signature is
  the trust root, not the transport.
* Tenant-scoped service-to-service uses **S2S client-credentials** (`exchange_client_credentials()`), already in
  production. Root-tenant S2S work is authorized through the same `PolicyEnforcer` path as any tenant.
* Gear developers write no auth code — middleware handles everything.

### Platform plane (infrastructure: Directory/GTS registration, heartbeats)

* Platform-scoped (non-tenant) operations carry a **dedicated, independent `PlatformSecurityContext`** — a distinct type
  from the tenant `SecurityContext`, **not** a tenant context with a nil/sentinel tenant id. This is a deliberate
  separation-of-mechanisms decision: reusing the request-scoped tenant `SecurityContext` for platform calls is
  the seam where confused-deputy / cross-tenant-leak regressions start (see the existing `am.system` system-actor
  factories in account-management, which mitigate exactly this risk by hand). A separate type makes "this call has no
  tenant" unrepresentable-as-a-tenant rather than encoded as `tenant_id = nil`.
* `PlatformSecurityContext` is **never** evaluated by the tenant `PolicyEnforcer`; platform-level APIs that bypass tenant
  AuthZ (e.g. resource-group's `ResourceGroupReadHierarchy` unscoped reads, account-management's `am.system` paths)
  consume it directly. `PeerAuthenticated` and the platform identity are for **workload policy only** — they never
  substitute for tenant-plane JWT validation.
* **The authentication method is decoupled from the context's structure.** The *context type* is stable from day one;
  the *authentication mechanism* is phased and MUST NOT change the shape of `PlatformSecurityContext` or how platform
  handlers consume it. **How the platform plane authenticates — SA tokens first, mTLS + SPIFFE next, and the
  `PlatformIdentity` / `InternalCredential` abstraction — is decided by
  [ADR-0006 (Platform-Plane Authentication)](0006-cpt-cf-adr-platform-plane-auth.md).**
* Ideally served on a **separate listener** from business endpoints.

### Plane selection by call type

A request carries exactly one plane. Note that *system-initiated* does not imply *platform plane* — tenant-scoped
system work stays on the tenant plane (see the criterion above).

| Call | Plane | Credential / header |
|------|-------|---------------------|
| External client → Gateway | Tenant | `Authorization: Bearer <jwt>` (user JWT) |
| Gateway → Gear (user request) | Tenant | `Authorization: Bearer <jwt>` — re-validated each hop |
| Gear → Gear, user-propagated | Tenant | `Authorization: Bearer <jwt>` — re-validated each hop |
| Gear → Gear/infra, **tenant-scoped** system work (no user) | Tenant | `Authorization: Bearer <jwt>` (S2S client-credentials) — re-validated each hop |
| Gear → Directory/GTS, **non-tenant** platform work | Platform | `X-ToolKit-Internal-Token` (SA token; mTLS+SPIFFE next phase) → `PlatformSecurityContext` |

The header/mechanism details for the platform-plane row are owned by
[ADR-0006](0006-cpt-cf-adr-platform-plane-auth.md).

### The context type *is* the plane marker (how codegen selects the carrier)

The two planes are carried by **two distinct types** — the tenant `SecurityContext` and the platform
`PlatformSecurityContext` — and that distinction is what selects the wire carrier. There is no runtime "plane flag"
or role on a single context: the type in the signature *is* the marker. This makes plane selection a **compile-time**
property and keeps "this call has no tenant" unrepresentable-as-a-tenant (the separation-of-mechanisms goal above).

This pins the convention for the contract-binding codegen (`#[toolkit_rest_contract]` /
[binding constraint `cpt-cf-binding-constraint-security-context`](../../toolkit-contract-binding/DESIGN.md)): a
remote-capable method takes **exactly one** plane context as its first argument, and the generated client maps it to
the matching carrier.

| Signature first arg | Plane | Generated carrier |
|---------------------|-------|-------------------|
| `&SecurityContext` | Tenant | `Authorization: Bearer <jwt>` |
| `&PlatformSecurityContext` | Platform | `X-ToolKit-Internal-Token` (Phase 1; see note below) |

The platform carrier is phase-specific: Phase 1 uses the `X-ToolKit-Internal-Token` header, Phase 2 (mTLS+SPIFFE)
moves it to the connection-level handshake and drops the per-request header. The type marker is stable either way —
codegen keys off the context type, so signatures don't change across phases.

Two responsibilities are deliberately split:

* **Carrier/plane selection** is a *contract* concern — decided at compile time by the context type, so a platform-plane
  call can never emit `Authorization` and a tenant-plane call can never emit `X-ToolKit-Internal-Token`.
* **Credential population** is a *runtime* concern — the actual secret (bearer JWT for tenant; SA token / mTLS identity
  for platform, per [ADR-0006](0006-cpt-cf-adr-platform-plane-auth.md)'s `InternalCredential` and `attach_internal_auth`)
  is injected *below* the contract layer. Gear code never sets an auth header.

So both "the contract carries a plane marker" and "the runtime attaches the credential out-of-band" are true at once,
on different axes: the **type** picks the slot, the **runtime** fills it. Codegen MUST NOT default a platform-plane
method to the `Authorization` carrier.

### Consequences

* **`x-secctx-bin` is removed from the HTTP contract.** `encode_bin` / `decode_bin` remain for in-process gRPC metadata
  (Profile 1), where the process boundary is the trust root.
* **Every OoP pod needs `AuthNResolverClient`** in its ClientHub (stateless, JWKS-cached). AuthN Resolver is embedded in
  each pod.
* **Platform-plane endpoints carry a `PlatformSecurityContext` (no tenant subject)** and SHOULD be served on a distinct
  listener. This is a separate type from the tenant `SecurityContext`, not a nil-tenant instance of it.
* **`PeerAuthenticated` / `PlatformIdentity` are for workload policy only** — they never substitute for tenant-plane JWT
  validation, and `PlatformSecurityContext` is never passed to the tenant `PolicyEnforcer`.
* **Per-hop cost** is ~0.5 ms (JWKS-cached). Deferred mitigation: bounded positive-result caching in the AuthN Resolver
  client SDK; triggered only if benchmarks exceed budget.

### NFR impact

| NFR | Auth-at-edge only | Two-plane (this model) |
|-----|-------------------|------------------------|
| `cpt-cf-nfr-per-hop-revalidation` | Zero JWT verifications in OoP | One per hop (~0.5 ms, JWKS-cached); within latency budget |
| `cpt-cf-nfr-oop-latency` | ~0 ms (secctx decode) | ~0.5 ms per hop; ~1 ms for a 2-hop chain; budget is 5 ms p95 |

### Confirmation

* Integration test: a request through the gateway to an OoP gear reconstructs `SecurityContext` from the JWT (not from
  `x-secctx-bin`).
* Security test: a forged `Authorization` header with an invalid signature is rejected with 401 — even when
  `PeerAuthenticated` is set (no peer-trust fast path in the first phase).
* Platform-plane test: a pod registers with DirectoryService using its SA token; a wrong-audience token is rejected.
* Type test: `PlatformSecurityContext` cannot be passed where a tenant `SecurityContext` is expected (compile-time), and
  the tenant `PolicyEnforcer` never receives a platform context.
* Latency benchmark: p95 per-hop JWT re-validation < 1 ms with warm JWKS cache.

## Pros and Cons of the Options

### Option A: Auth-at-edge only (unsigned `x-secctx-bin`, mTLS day one)

* Good, because zero per-hop crypto.
* Bad, because integrity depends on mTLS being correctly deployed everywhere — a single gap is privilege escalation.
* Bad, because it blocks the first OoP gear on finished PKI.

### Option B: Two planes (chosen)

* Good, because the JWT is self-authenticating — no dependency on transport trust for tenant integrity.
* Good, because it ships without PKI; SA tokens are K8s-native and cheap.
* Good, because SA→mTLS is a backend swap behind a stable abstraction — no throwaway gear code.
* Good, because it aligns with the zero-trust and platform-authn designs.
* Bad, because every OoP pod needs `AuthNResolverClient` and pays ~0.5 ms/hop (mitigable).

### Option C: HMAC-signed `x-secctx-bin`

* Good, because preserves zero-JWT-verification.
* Bad, because a shared secret allows forgery from any holder (no per-gear identity).
* Bad, because adds a fourth crypto mechanism alongside JWT, SA tokens, and mTLS.

## More Information

### Developer experience

The chosen model keeps the "developers write no auth code" property: middleware performs tenant-plane re-validation and
platform-plane credential attachment. Relative to the auth-at-edge option, only the *mechanism* differs — per-hop JWT
re-validation (trusting the IdP signature) instead of an unsigned envelope (trusting the transport).

### Relationship to the platform-plane ADR

This ADR defines the **model**: two planes, exactly one plane per request, and the `PlatformSecurityContext` contract
(a distinct, tenant-less type never evaluated by the tenant `PolicyEnforcer`). **How the platform plane authenticates —
SA tokens first, mTLS + SPIFFE next — and the `InternalCredential` / `PlatformIdentity` abstraction are decided by
[ADR-0006 (Platform-Plane Authentication)](0006-cpt-cf-adr-platform-plane-auth.md).** The two are peers: the model here
is stable regardless of which credential authenticates the platform plane.

### Existing platform-level surfaces this formalizes

These already-shipped in-process surfaces reuse the tenant `SecurityContext` for system work today. Applying the
tenant-scoped-vs-non-tenant criterion splits them:

* **account-management `for_gear_init`** (`domain/system_actor.rs`) — registers a *global* RG type (workspace-wide, nil
  tenant). Genuinely non-tenant → migrate to `PlatformSecurityContext`.
* **account-management tenant-bound factories** (`for_bootstrap`, `for_provisioning_reaper`, `for_retention_sweep`,
  `for_user_groups_cascade`) — operate on a specific tenant (including the platform root). These are *tenant-scoped* →
  they belong on the **tenant plane** with a real tenant `SecurityContext` (S2S client-credentials), **not**
  `PlatformSecurityContext`. The file's own doc comment already flags the cross-tenant-leak risk of the current nil/real
  tenant shim.
* **resource-group** — `ResourceGroupReadHierarchy` unscoped reads (`read_service.rs`) use `AccessScope::allow_all()` and
  are explicitly "do NOT expose via REST"; consumed in-process by the AuthZ and tenant-resolver plugins (the PDP cannot
  evaluate itself). Non-tenant infra read → `PlatformSecurityContext`.

## Traceability

- **PRD**: [PRD.md](../PRD.md)
- **DESIGN**: [DESIGN.md](../DESIGN.md)

This decision directly addresses the following requirements or design elements:

* `cpt-cf-fr-secctx-propagation` — Changes propagation from two-header to JWT-only (re-validated per hop)
* `cpt-cf-nfr-per-hop-revalidation` — One JWT verification per hop (~0.5 ms), within the 5 ms budget
* `cpt-cf-fr-internal-auth` — Platform plane uses a tenant-less `PlatformSecurityContext`; its credential mechanism is
  decided by ADR-0006 (Platform-Plane Authentication)
* `cpt-cf-adr-platform-plane-auth` — Decides how the platform plane defined by this model authenticates (SA tokens
  first, mTLS + SPIFFE next)
* `cpt-cf-component-secctx-http` — Simplifies to JWT extraction + `AuthNResolverClient` call (one code path)
* `cpt-cf-interface-secctx-http` — Simplifies the interface to a single `Authorization` header
