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

# Platform-Plane Authentication — SA Tokens First, mTLS + SPIFFE Next

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

## Table of Contents

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

## Context and Problem Statement

The [two-plane model](0008-cpt-cf-adr-two-plane-auth.md) separates **tenant identity** (the user, carried by a per-hop
re-validated JWT) from **platform identity** (the calling gear). This ADR decides **how the platform plane
authenticates** — how a gear proves *which gear it is* when it makes **system-initiated calls that carry no user
context**: registering with DirectoryService (Flight Control), heartbeats, global GTS registration, and
admin/background inter-gear calls.

These calls cross process and network boundaries in Profiles 2 and 3. Without platform-plane authentication, any
process that can reach DirectoryService can register itself as a gear or deregister others. How should a gear prove its
identity, should the mechanism differ per deployment profile, and how does it roll out without blocking the first OoP
gear on a finished PKI?

## Decision Drivers

* **Profile-appropriate security**: Profile 2 single-node (UDS, same host) has a fundamentally different threat model
  than Profile 3 (k8s, network-accessible pods). The mechanism should match the risk.
* **Ship the first OoP gear without PKI**: the first phase must not block on a finished CA / enrollment story.
* **No throwaway work**: the first mechanism must be a *backend* of the eventual abstraction, not a dead end.
* **No custom PKI for P1 scope**: mTLS with a custom CA is operationally expensive; P1 should use simpler mechanisms
  where the threat model allows it.
* **Zero developer burden**: gear developers must not implement auth logic for system calls — the ToolKit runtime
  handles it transparently.
* **K8s-native where possible**: in Profile 3, prefer k8s-provided identity (ServiceAccount tokens) over custom
  mechanisms.
* **Compatibility with existing gRPC and HTTP transport**: the mechanism must work for both gRPC metadata
  (DirectoryService) and HTTP headers (inter-gear REST).

## Considered Options

* **Option A**: Bootstrap token for all profiles — Platform Host generates an ephemeral token at startup, passes to
  workers via env var.
* **Option B**: mTLS for all profiles — every gear has a certificate signed by a platform CA.
* **Option C**: Profile-specific, phased — bootstrap token for Profile 2 single-node, K8s ServiceAccount tokens for
  Profile 3, migrating to mTLS+SPIFFE as the end state.
* **Option D**: Network-level trust only — rely on UDS permissions (Profile 2) and k8s NetworkPolicy (Profile 3), no
  application-level auth.
* **Option E**: Service-to-service JWT — every gear is provisioned with a service identity (e.g.
  `subject_type="application", subject_id="<gear-uuid>"`); calls carry an `Authorization: Bearer <jwt>` validated by
  the same `AuthNResolverClient` used for user JWTs.

## Decision Outcome

**mTLS + SPIFFE is the end-state trust root for the platform plane — but it is reached in phases, not adopted on day
one (Option C as the path to Option B).**

Sequencing:

1. **First phase — K8s ServiceAccount tokens (steady state, not just enrollment).** A gear attaches its projected SA
   token as `X-ToolKit-Internal-Token`; the receiver validates it via the k8s TokenReview API (cached TTL) and builds a
   `PlatformIdentity::ServiceAccount`. Zero new infrastructure, K8s-native, auto-rotated. This ships the first OoP gear
   without any PKI. Profile 2 single-node uses a bootstrap token (or plain UDS when all gears share a uid).
2. **Next phase — mTLS + SPIFFE.** cert-manager (K8s) / an embedded CA (on-prem) mints a workload certificate
   (`SAN URI = spiffe://<trust_domain>/gear/<gear>/<version>`). The TLS handshake establishes peer identity *before* any
   application payload is read, building a `PlatformIdentity::Spiffe` and providing defense in depth alongside the
   tenant plane's per-hop JWT re-validation. The first-phase credential **doubles as the enrollment credential** for
   this transition: a gear authenticates to cert-manager / SPIRE with its projected SA token (Profile 3) or its
   bootstrap token (Profile 2) to obtain its workload certificate, so the migration needs no separate enrollment secret.

SA token and mTLS are **two backends of one stable abstraction** — swapping does not change gear code. Outbound, the
runtime selects an `InternalCredential`; inbound, `InternalAuthMiddleware` validates it and produces a
`PlatformIdentity`. The platform plane's *context type* (`PlatformSecurityContext`, defined by the
[two-plane model ADR](0008-cpt-cf-adr-two-plane-auth.md)) is stable across both phases; only the populating mechanism —
and thus the `PlatformIdentity` variant — changes.

**Why not mTLS on day one (Option B directly):** mTLS is the destination, but tying the very first OoP deployment to a
finished CA / enrollment story delays everything. SA tokens are a real, K8s-native first backend of the same
abstraction, not throwaway work.

**Why not network-level trust (Option D):** UDS perms and NetworkPolicy block the wrong attackers (external) and do
nothing against a compromised sibling pod inside the trust domain — except in the one narrow Profile 2 single-node case
where all gears share a uid over UDS.

| Profile            | First-phase mechanism                       | End-state mechanism                | Notes                                                        |
|--------------------|---------------------------------------------|------------------------------------|--------------------------------------------------------------|
| 1 (Embedded)       | n/a (same process)                          | n/a                                | No transport, no auth needed                                 |
| 2 (single-node)    | Bootstrap token over UDS, or plain UDS      | mTLS over loopback (optional)      | OS process boundary + fs perms acceptable if all gears run as same uid |
| 2 (multi-node, P2) | Bootstrap token for first enrollment        | **mTLS with platform CA (required)** | Plain TCP rejected at the listener once mTLS lands           |
| 3 (K8s)            | **K8s SA token + TokenReview**              | **mTLS + SPIFFE (cert-manager)**   | NetworkPolicy is defense-in-depth, not the primary boundary  |

### Credential and identity abstraction

```rust
// Outbound: what a gear attaches to its system calls (selected by profile/config).
pub enum InternalCredential {
    /// Profile 1: no auth needed (in-process)
    None,
    /// Profile 2 single-node: ephemeral token from Platform Host
    BootstrapToken(SecretString),
    /// Profile 3 first phase: k8s ServiceAccount JWT (auto-mounted, auto-rotated)
    KubeServiceAccountToken { token_path: PathBuf, audience: String },
    /// End state (and Profile 2 multi-node, P2): mTLS client identity
    MtlsIdentity { cert: PathBuf, key: PathBuf, ca: PathBuf },
}

// Inbound: the validated identity InternalAuthMiddleware produces, carried inside PlatformSecurityContext.
#[non_exhaustive]
pub enum PlatformIdentity {
    /// First phase: from a validated projected SA token (TokenReview).
    ServiceAccount { namespace: String, service_account: String, pod: Option<String> },
    /// Next phase: mTLS + SPIFFE, parsed from the X.509 SAN.
    Spiffe { trust_domain: String, gear: String, version: String },
}
```

The OoP bootstrap reads the credential source from configuration and creates the appropriate `InternalCredential`
variant. All outgoing system calls go through `attach_internal_auth(request, credential)`, which sets the correct
header/metadata (`X-ToolKit-Internal-Token` for token-based phases — never `Authorization`, to avoid colliding with the
tenant-plane user JWT). `PlatformSecurityContext` itself — the wrapper type, its no-tenant guarantee, and the rule that
it is never passed to the tenant `PolicyEnforcer` — is defined by the [two-plane model ADR](0008-cpt-cf-adr-two-plane-auth.md);
this ADR owns how its `PlatformIdentity` is *populated*.

**Carrier selection vs. credential population.** Which carrier a system call uses is *not* chosen by this credential
machinery — it is chosen at compile time by the context type in the contract signature. A method that takes
`&PlatformSecurityContext` is a platform-plane call and the generated client emits `X-ToolKit-Internal-Token`; a method
that takes `&SecurityContext` is a tenant-plane call and emits `Authorization` (see the
[two-plane model ADR](0008-cpt-cf-adr-two-plane-auth.md), "The context type *is* the plane marker"). `attach_internal_auth`
then fills that platform-plane carrier with the actual secret from the selected `InternalCredential`. The contract picks
the slot; this ADR's runtime fills it — so gear code never sets an auth header and codegen can never default a
platform-plane call to `Authorization`.

### Consequences

* **Phase 1 validates a per-request token.** Each inbound system call carries `X-ToolKit-Internal-Token` (SA token),
  validated via TokenReview (cached). `InternalAuthMiddleware` builds `PlatformIdentity::ServiceAccount` and sets
  `PeerAuthenticated { gear }` for workload-policy decisions only.
* **The end state removes the per-request token from the hot path.** Once mTLS lands, peer identity is established by
  the TLS handshake (the cert-derived `PlatformIdentity::Spiffe`), and `InternalAuthMiddleware` becomes a thin
  shim asserting the connection-level identity — there is no per-request token to validate.
* **Plain-TCP rejection applies once mTLS lands.** In the mTLS end state, the OoP runtime MUST refuse plain-TCP inbound
  on every multi-process profile (the listener requires client-certificate verification against the platform CA, and no
  application payload is read before the handshake completes). In the SA-token first phase, transport confidentiality
  comes from the platform network / TLS termination, and the SA token authenticates the caller.
* Cert lifecycle is delegated to a cert-manager / SPIRE-like component out of scope of this ADR; ToolKit consumes
  rotated certs from a well-known path (Profile 2: file watched by `notify`; Profile 3: projected volume).
* Flight Control validates inbound `RegisterInstance` / `DeregisterInstance` / `Heartbeat` by the validated
  `PlatformIdentity` (the `ServiceAccount` variant in phase 1; the `Spiffe` variant from mTLS next), not by a bespoke token scheme.
* `InternalAuthMiddleware` inserts two values from the validated credential: `PeerAuthenticated { gear }` — a
  lightweight marker for **workload-policy** checks — and `PlatformSecurityContext { identity }` — the identity object
  that AuthZ-exempt platform handlers consume. Neither is ever passed to the tenant `PolicyEnforcer`, and neither
  substitutes for tenant-plane JWT validation.
* Profile 2 single-node MAY skip mTLS if all gears run as the same uid and communicate over UDS — the OS process
  boundary plus filesystem perms is the trust root in that narrow case (the one place Option D applies).
* The optional JWT mode (`internal_auth.mode = "jwt"`, Option E) coexists as an escape hatch for operators who want one
  auth backend for users and gears; it is **not** the default.
* The platform plane SHOULD be served on a listener separate from business endpoints.

### Confirmation

* Integration test (Profile 2): an unauthorized process attempts `RegisterInstance` without the bootstrap token —
  rejected with `UNAUTHENTICATED`.
* Integration test (Profile 3): a pod registers with DirectoryService using its SA token — accepted; a wrong-audience
  token is rejected.
* Code review: no gear source code touches `InternalCredential` or auth headers for system calls — all handled by the
  ToolKit runtime.
* Abstraction test: adding the `Spiffe` mechanism populates the same `PlatformSecurityContext` and does not change how
  platform handlers consume `PlatformIdentity`.

## Pros and Cons of the Options

### Option A: Bootstrap Token for All Profiles

A single random token generated by Platform Host, shared with all workers.

* Good, because simple — one mechanism, one code path, no PKI.
* Good, because zero external dependencies (no k8s API, no CA).
* Neutral, because the token is a shared secret — no per-gear identity.
* Bad, because in k8s a leaked Secret lets any pod impersonate any gear.
* Bad, because it provides no gear-level identity.
* Bad, because in multi-node Profile 2 the token travels the network without TLS unless layered separately.

### Option B: mTLS for All Profiles (the end state)

Every gear gets a certificate from a platform CA; TLS client auth proves identity.

* Good, because strongest security — mutual auth, per-gear identity, encrypted transport.
* Good, because it works across all network topologies (UDS, TCP, k8s).
* Bad, because it requires PKI — CA management, rotation, revocation.
* Bad, because it is operationally heavy for Profile 2 single-node (same-host UDS).
* Bad, because adopting it on **day one** blocks the first OoP gear on a finished CA — hence the phased rollout.

### Option C: Profile-Specific, Phased (chosen path)

Bootstrap token (Profile 2 single-node) / K8s SA tokens (Profile 3) now, migrating to mTLS+SPIFFE as the end state.

* Good, because security matches the threat model — no over-engineering for low-risk profiles.
* Good, because k8s SA tokens are zero-config (auto-mounted, auto-rotated) and give per-pod identity.
* Good, because the `InternalCredential` / `PlatformIdentity` abstraction hides the mechanism from gear code.
* Good, because each phase is a real backend of the eventual abstraction — no throwaway work.
* Neutral, because the runtime needs a small validation layer that dispatches by credential type.
* Bad, because Profile 3 SA-token validation requires the k8s TokenReview API (mitigated by caching).

### Option D: Network-Level Trust Only

No application-level auth; trust UDS permissions and k8s NetworkPolicy.

* Good, because zero implementation effort and no validation latency.
* Bad, because a compromised same-host process (Profile 2) can call DirectoryService freely.
* Bad, because k8s NetworkPolicy is namespace-level, not pod-level — any pod can impersonate any gear.
* Bad, because no audit trail and does not meet enterprise/regulated requirements.

### Option E: Service-to-Service JWT

Every gear is provisioned a service identity; system calls carry an `Authorization: Bearer <jwt>`.

* Good, because it reuses the existing `AuthNResolverClient` and AuthZ machinery.
* Neutral, because it conflates platform identity into the tenant-plane validation path.
* Bad, because it does not provide transport-level peer authentication (defense in depth) the way mTLS does.
* Retained only as an opt-in `internal_auth.mode = "jwt"` escape hatch, not the default.

## More Information

### Relationship to the two-plane model

This ADR is the platform-plane half of the [two-plane model](0008-cpt-cf-adr-two-plane-auth.md). The model ADR decides
*that* there are two planes, that a request carries exactly one, and that platform calls use a distinct
`PlatformSecurityContext`. This ADR decides *how* the platform plane authenticates and how that rolls out.

### Relationship to edge architecture (ADR-0007)

Platform-plane auth is orthogonal to which edge gateway is deployed (Mode A built-in vs Mode B external) — it secures
system calls regardless of the edge stack.

## Traceability

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

This decision directly addresses the following requirements or design elements:

* `cpt-cf-fr-internal-auth` — Directly satisfies the platform-plane (internal) authentication requirement.
* `cpt-cf-fr-oop-lifecycle` — Registration and deregistration calls to DirectoryService are authenticated via this
  mechanism.
* `cpt-cf-fr-eventual-readiness` — Background self-registration retries carry the internal credential.
* `cpt-cf-component-oop-bootstrap` — The OoP bootstrap initializes the `InternalCredential` and attaches it to all
  system calls.
* `cpt-cf-component-platform-plane-auth` — The platform-plane-auth component implements `InternalCredential`,
  `PlatformIdentity`, and `InternalAuthMiddleware`.
* `cpt-cf-adr-two-plane-auth` — Defines the two-plane model whose platform plane this ADR's mechanism authenticates.
