# PRD prd-new: Uniform control-plane auth provisioning - **Status:** Draft - **Author:** claude - **Created:** 2026-07-26 - **Issue:** #476 ## Summary Hoist control-plane auth provisioning out of the per-backend launchers into a single shared contract, parameterized per **trust domain**. A backend obtains its signing key and the data plane's token through one seam (`trust_domain.ControlPlaneProvisioning`) instead of re-deriving, by hand, how to generate the signing key, scope it to the orchestrator, mint the `gateway` JWT, and keep the host key canonical. This removes the integration-bug class that took PR #471 three review rounds to land, and gives issue #468's host controller a clean seam to instantiate its **own** domain (own key, own roles) without weakening the control plane's. ## Problem Each launcher (`docker` gateway, `docker` infra, `macos` infra, `firecracker` infra) implemented the control-plane auth invariants independently. Every blocking finding in PR #471 was the same class of *integration* bug — not a flaw in the auth primitive (`orchestrator_auth.mint`/`verify`), but in how each backend wired it: - **Round 1 (High):** the data plane got the full-power control-plane token, so a compromised egress/git-gate could approve its own supervise proposals. Fixed with role-scoped JWTs (`gateway` vs `cli`). - **Round 2 (High):** the Firecracker infra VM never provisioned the signing key or a `gateway` JWT, so the control plane fell into **open mode** and handed every unauthenticated caller the `cli` role. - **Round 3 (High):** the Firecracker fix then clobbered the host-canonical `orchestrator-token` with its guest key, 401'ing every already-running Docker/macOS orchestrator. Miss one step per bespoke launcher and it's either a security hole or a cross-backend coexistence regression — and the tests didn't catch it because each backend's provisioning was hand-rolled. ## Goals / Success Criteria - One shared seam every backend satisfies for control-plane auth provisioning; no launcher re-derives the four invariants. - The signing key is **host-canonical** — a guest is handed it, never generates or overwrites it. - Only the orchestrator process receives the raw key; the data plane receives a pre-minted, role-scoped `gateway` token it cannot rewrite into `cli`. - The host CLI's `cli` token is minted from the same canonical key, so it stays valid across simultaneously-running backends. - Open mode is unreachable for any backend whose data plane shares a host/VM with the control plane (fail-closed by default). - Provisioning is parameterized **per trust domain**, so #468 adds a separate host-controller domain (own key, own verifier, own role set) rather than a `host` role on the control plane's frozenset. - Adding a backend or a data-plane daemon means *implementing the contract*, not rediscovering the invariants. ## Non-goals - Not a rewrite of the auth primitive. `orchestrator_auth`'s HMAC mint/verify is unchanged except for an optional `roles=` argument (default preserved) so a domain can scope its own role set. - Not the #468 host-controller domain itself — this only provides the seam it will instantiate. - Not a change to network topology, the plane split (#469), or the server's documented open-mode fallback for tests/isolated control planes. - The complementary #469 hardening (distinct non-root UIDs for co-located daemons) stays separate. ## Design ### Trust domain — the unit of provisioning A **trust domain** (`trust_domain.TrustDomain`) is one credential boundary: a host-canonical signing key file, the role set that key may sign, and the env vars the raw key and a pre-minted token ride in. `mint`/`verify` are scoped to the domain's roles, so a token minted in one domain neither carries nor verifies a role from another. The orchestrator control plane is one domain, `CONTROL_PLANE` (key `orchestrator-token`, roles `{gateway, cli}`). The security reason provisioning is per-domain and not per-key: adding a `host` role to `CONTROL_PLANE.roles` would let anything holding the control-plane key (the orchestrator itself) mint host-controller tokens, collapsing the boundary #468 needs — the host controller owns the orchestrator's lifecycle, so it must not be forgeable *by* the orchestrator. Two keys, two verifiers, two role sets. `paths.host_signing_key(filename)` generalizes the old `host_orchestrator_token()` (now a thin specialization) so each domain names its own host-canonical key file. ### The provisioning contract `ControlPlaneProvisioning` composes a domain with a declared `Topology` and answers the four invariants once: - `orchestrator_key()` → the raw key the control-plane **process** receives. Fail-closed: raises `ProvisioningError` for a co-located topology when the key is empty (which would run the server OPEN). - `gateway_token()` → the pre-minted `gateway` token the data plane receives, minted from the canonical key, never the key itself. The `Orchestrator` ABC (`orchestrator/lifecycle.py`) holds one `ControlPlaneProvisioning` and exposes `control_plane_key()` and `mint_gateway_token()` over it. Each backend's orchestrator obtains its key through `control_plane_key()` and applies it via its own transport (docker/macOS: env var `key_env`; firecracker: SSH push to the guest) — the transport differs, the derivation no longer does. ### Topology — the backend declares what it is `Topology` captures the provisioning-relevant dimensions the issue names (combined-guest vs standalone, data plane co-located vs isolated). The default, `COLOCATED`, makes the signing key mandatory (fail-closed). Every current backend is co-located (docker/macOS: two containers on one host; firecracker: two VMs on one host, agents L3-isolated), so none needs to redeclare it — the safe posture is the default, and a genuinely isolated control plane opts out explicitly. ### Data flow ``` host key file (per-domain, 0600, host-canonical) │ paths.host_signing_key(domain.key_filename) ▼ TrustDomain ── mint(role) ─────────────► gateway token ─► data-plane process (token_env / SSH) │ signing_key() (gateway role, unrewritable) ▼ ControlPlaneProvisioning.orchestrator_key() ─► control-plane process (key_env / SSH) │ (fail-closed for co-located topology) ▼ OrchestratorClient ── CONTROL_PLANE.mint(cli) ─► host CLI's own operator token ``` ## Open questions None blocking. #468 will add its host-controller domain as a second `TrustDomain` + `ControlPlaneProvisioning`-shaped consumer; whether the provisioning class is renamed to a domain-neutral `DomainProvisioning` at that point is a cosmetic call to make when #468 lands.