Hoist control-plane auth provisioning out of the per-backend launchers into one shared contract, parameterized per trust domain (#476). Every blocking finding in PR #471 was the same integration-bug class: each launcher re-derived, by hand, how to generate the signing key, scope it to the orchestrator, mint the gateway JWT, and keep the host key canonical. Introduces `trust_domain.py`: * `TrustDomain` — one credential boundary (host-canonical key file + role set + env vars). `mint`/`verify` are scoped to the domain's roles, so a future host-controller domain (#468) uses its own key/verifier/roles rather than a `host` role on the control plane's frozenset (which the orchestrator key could then forge). * `ControlPlaneProvisioning` — the single seam answering the four invariants: host-canonical key, split key-vs-token credential, CLI token valid across co-running backends, and fail-closed (no open mode) for any co-located topology. * `Topology` — the backend declares what it is; the default is co-located + fail-closed, so a backend need not redeclare it. The `Orchestrator` ABC gets `control_plane_key()` (fail-closed) and routes `mint_gateway_token()` through the contract; docker/macOS/firecracker orchestrators, the server (verify), and the host CLI client (mint cli) all go through the domain instead of reading the host key directly. `orchestrator_auth` gains an optional `roles=` arg (default unchanged) so a domain scopes its own role set; `paths.host_signing_key(filename)` generalizes host_orchestrator_token. Adds unit coverage for the domain boundary + provisioning invariants and a PRD capturing the durable rationale. No change to the auth primitive's HMAC, the plane split, or the server's documented open-mode fallback. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Closes #476
6.7 KiB
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 (
gatewayvscli). - Round 2 (High): the Firecracker infra VM never provisioned the signing key
or a
gatewayJWT, so the control plane fell into open mode and handed every unauthenticated caller theclirole. - Round 3 (High): the Firecracker fix then clobbered the host-canonical
orchestrator-tokenwith 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
gatewaytoken it cannot rewrite intocli. - The host CLI's
clitoken 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
hostrole 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 optionalroles=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: raisesProvisioningErrorfor a co-located topology when the key is empty (which would run the server OPEN).gateway_token()→ the pre-mintedgatewaytoken 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.