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

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 (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.