Files
bot-bottle/docs/prds/prd-new-control-plane-auth-provisioning.md
T
didericis-claude 45f3cefbc5 refactor(orchestrator): uniform control-plane auth provisioning per trust domain
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
2026-07-26 01:32:53 +00:00

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.