docs(orchestrator): tighten auth-provisioning wording to concrete services
test / integration-docker (pull_request) Successful in 18s
tracker-policy-pr / check-pr (pull_request) Successful in 21s
test / integration-firecracker (pull_request) Successful in 3m51s
test / unit (pull_request) Failing after 11m53s
test / coverage (pull_request) Has been skipped
test / publish-infra (pull_request) Has been skipped
test / integration-docker (pull_request) Successful in 18s
tracker-policy-pr / check-pr (pull_request) Successful in 21s
test / integration-firecracker (pull_request) Successful in 3m51s
test / unit (pull_request) Failing after 11m53s
test / coverage (pull_request) Has been skipped
test / publish-infra (pull_request) Has been skipped
Address review on #482: drop the generic "credential boundary" framing in the PRD and docstrings and talk about the specific services — the orchestrator holds the control-plane key; the host controller (#468) gets a separate key the orchestrator never holds, so the orchestrator can't mint the credentials it uses to talk to the host controller that owns its lifecycle. Lead with that concrete win. No code behaviour change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
# PRD prd-new: Uniform control-plane auth provisioning
|
||||
# PRD prd-new: Per-service signing keys for control-plane auth
|
||||
|
||||
- **Status:** Draft
|
||||
- **Author:** claude
|
||||
@@ -7,135 +7,82 @@
|
||||
|
||||
## 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.
|
||||
Provision control-plane signing keys **per service**, through one shared seam, so
|
||||
no service can mint another's credentials. Concretely: the orchestrator holds the
|
||||
control-plane key and mints the gateway's and CLI's tokens; the host controller
|
||||
(#468, next) gets a **separate** key the orchestrator never holds — so the
|
||||
orchestrator cannot forge the credentials it uses to talk to the host controller
|
||||
that starts and stops it. Landing this seam also retires the per-backend
|
||||
provisioning duplication that made PR #471 take three review rounds.
|
||||
|
||||
## 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:
|
||||
**1. The orchestrator could forge host-controller credentials.** The
|
||||
orchestrator's key signs roles `{gateway, cli}`. The tempting way to add the host
|
||||
controller (#468) is a third role, `host`, on that same key. But then the
|
||||
orchestrator — which holds the key — can mint `host` tokens, and the host
|
||||
controller, which owns the orchestrator's lifecycle, must not trust anything the
|
||||
orchestrator can mint. The two services need separate keys.
|
||||
|
||||
- **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.
|
||||
**2. Every backend provisioned auth by hand.** Each launcher (docker
|
||||
gateway/infra, macOS infra, firecracker infra) re-derived how to generate the
|
||||
signing key, scope it to the orchestrator, mint the gateway JWT, and keep the
|
||||
host key file canonical. All three PR #471 High-severity findings were this one
|
||||
integration bug in different launchers: the data plane got the full `cli` token;
|
||||
the firecracker control plane ran open; the firecracker guest clobbered the host
|
||||
key.
|
||||
|
||||
## 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.
|
||||
- The orchestrator and the host controller sign with **different** keys; neither
|
||||
can mint the other's tokens. (This PR provisions the orchestrator's key and
|
||||
leaves a drop-in seam for the host controller's.)
|
||||
- One shared provisioning seam every backend uses — a new backend or daemon
|
||||
implements it instead of rediscovering these four invariants:
|
||||
1. the signing key is host-canonical: a guest is handed it, never generates or
|
||||
overwrites it;
|
||||
2. only the orchestrator process gets the raw key; the gateway gets a
|
||||
pre-minted `gateway` token it can't rewrite into `cli`;
|
||||
3. the host CLI's `cli` token is minted from the same key, so it stays valid
|
||||
across co-running backends;
|
||||
4. the control plane never runs open where its data plane shares the host/VM.
|
||||
|
||||
## 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.
|
||||
- The host controller itself (#468) — this only provisions the orchestrator's
|
||||
key and the seam #468 plugs into.
|
||||
- Rewriting the HMAC primitive: `orchestrator_auth.mint/verify` gain an optional
|
||||
`roles=` arg (default unchanged) so a key can carry a different role set;
|
||||
nothing else changes.
|
||||
- Network topology, the plane split (#469), or the server's open-mode fallback
|
||||
for tests.
|
||||
|
||||
## Design
|
||||
|
||||
### Trust domain — the unit of provisioning
|
||||
A **`TrustDomain`** is one service's signing material: its host-canonical key
|
||||
file, the roles that key may sign, and the env vars its key and a pre-minted
|
||||
token ride in. `mint`/`verify` are scoped to that domain's roles, so a token
|
||||
signed by one service's key neither carries nor verifies another service's role.
|
||||
|
||||
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.
|
||||
- `CONTROL_PLANE` — the orchestrator's domain: key `orchestrator-token`, roles
|
||||
`{gateway, cli}`. The orchestrator process holds the key; the gateway holds
|
||||
only a minted `gateway` token; the host CLI mints its own `cli` token.
|
||||
- The host controller (#468) will add a second `TrustDomain` — its own key file
|
||||
and role(s) — that the orchestrator never holds.
|
||||
|
||||
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.
|
||||
**`ControlPlaneProvisioning`** is the seam the backends call.
|
||||
`orchestrator_key()` returns the raw key for the control-plane process
|
||||
(fail-closed: it raises rather than hand back an empty key that would run the
|
||||
server open where the data plane is co-located). `gateway_token()` mints the
|
||||
gateway's token. Each backend applies these through its own transport —
|
||||
docker/macOS inject env vars, firecracker pushes over SSH — but none re-derives
|
||||
*which* key or role.
|
||||
|
||||
`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
|
||||
```
|
||||
`paths.host_signing_key(filename)` generalizes `host_orchestrator_token()` so each
|
||||
domain names its own key file.
|
||||
|
||||
## 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.
|
||||
None blocking. #468 adds its `TrustDomain` and a second
|
||||
`ControlPlaneProvisioning`-shaped consumer; renaming that class to something
|
||||
service-neutral is a cosmetic call to make then.
|
||||
|
||||
Reference in New Issue
Block a user