45f3cefbc5
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
142 lines
6.7 KiB
Markdown
142 lines
6.7 KiB
Markdown
# 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.
|