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:
@@ -62,15 +62,13 @@ _HEADER_SEGMENT = _b64url_encode(
|
||||
def mint(role: str, secret: str, *, roles: frozenset[str] = ROLES) -> str:
|
||||
"""A compact HS256 token asserting `role`, signed with `secret`.
|
||||
|
||||
`roles` is the role set the caller's *trust domain* recognises (default: the
|
||||
orchestrator control plane's `{gateway, cli}`). A domain names its own set so
|
||||
each credential boundary mints only its own roles — a separate boundary
|
||||
(e.g. a host controller) instantiates a distinct domain with a distinct key
|
||||
and role set rather than adding a role here, so its key cannot forge the
|
||||
other domain's tokens (see `trust_domain.py`, issues #476/#468).
|
||||
`roles` is the set the signing key is allowed to sign (default: the
|
||||
orchestrator's `{gateway, cli}`). A separate service (e.g. the host
|
||||
controller) passes its own key + role set so its tokens can't be forged with
|
||||
the orchestrator's key — see `trust_domain.py`, issues #476/#468.
|
||||
|
||||
Raises ValueError for a role outside `roles` (mint only what that domain will
|
||||
accept) or an empty signing key (an unsigned credential is never valid)."""
|
||||
Raises ValueError for a role outside `roles`, or an empty signing key (an
|
||||
unsigned credential is never valid)."""
|
||||
if role not in roles:
|
||||
raise ValueError(f"unknown control-plane role {role!r}")
|
||||
if not secret:
|
||||
|
||||
+6
-8
@@ -101,14 +101,12 @@ def host_signing_key(filename: str) -> str:
|
||||
"""A per-host signing key at `<root>/<filename>`, minted (256-bit, url-safe)
|
||||
and persisted 0600 on first use, then reused.
|
||||
|
||||
The generic form of `host_orchestrator_token()`: a *trust domain*
|
||||
(`trust_domain.py`) names its own key file so each credential boundary gets a
|
||||
distinct host-canonical key — the orchestrator control plane names one file,
|
||||
a separate boundary (e.g. a host controller) names another, and neither can
|
||||
read the other's key (issues #476/#468). It is a *host* artifact: the file
|
||||
lives under the root the agent never mounts, and its value is injected only
|
||||
into the trusted control-plane process, so reading it here is safe on the
|
||||
host launch path but the value never reaches a bottle."""
|
||||
The generic form of `host_orchestrator_token()`: each service names its own
|
||||
key file (`trust_domain.py`), so the orchestrator and a separate service like
|
||||
the host controller (#468) get distinct keys neither can read. It is a *host*
|
||||
artifact — the file lives under the root the agent never mounts, and its value
|
||||
is injected only into the trusted control-plane process — so reading it here
|
||||
is safe on the launch path but the value never reaches a bottle."""
|
||||
path = bot_bottle_root() / filename
|
||||
try:
|
||||
existing = path.read_text().strip()
|
||||
|
||||
+64
-86
@@ -1,29 +1,25 @@
|
||||
"""Trust domains: the unit of control-plane auth provisioning (issue #476).
|
||||
"""Per-service control-plane signing keys (issue #476).
|
||||
|
||||
A *trust domain* is one **credential boundary** — a single host-canonical
|
||||
signing key plus the role set that key is allowed to sign, plus the env vars the
|
||||
key and a pre-minted token are carried in. Provisioning is parameterized *per
|
||||
domain*, not per key: the orchestrator control plane is one domain
|
||||
(`CONTROL_PLANE` — key `orchestrator-token`, roles `{gateway, cli}`); a future
|
||||
boundary (e.g. the host controller of #468) instantiates its **own** domain with
|
||||
its **own** key, verifier, and role set rather than adding a role to this one.
|
||||
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. Scoping `mint`/`verify` to a domain's roles keeps one service's key from
|
||||
signing (or accepting) another service's tokens.
|
||||
|
||||
That distinction is the security invariant behind the split: adding a `host`
|
||||
role to the control plane's `ROLES` frozenset would let anything holding the
|
||||
control-plane signing 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.
|
||||
Today there is one domain, `CONTROL_PLANE` — the orchestrator's key (roles
|
||||
`{gateway, cli}`): the orchestrator holds it and mints the gateway's and CLI's
|
||||
tokens. The host controller (#468) will add a **second** domain with its own key
|
||||
the orchestrator never holds. That is the point: the host controller starts and
|
||||
stops the orchestrator, so the orchestrator must not be able to mint the
|
||||
credentials it uses to talk to it. Adding a `host` role to `CONTROL_PLANE`
|
||||
instead would defeat that — the orchestrator holds that key, so it could forge
|
||||
`host` tokens.
|
||||
|
||||
`ControlPlaneProvisioning` is the single shared contract every backend launcher
|
||||
satisfies instead of re-deriving, by hand, how to generate the signing key,
|
||||
scope it to the orchestrator process, mint the gateway JWT, and keep the host
|
||||
key canonical (the bug class that took PR #471 three review rounds — see
|
||||
`ControlPlaneProvisioning` is the one seam every backend launcher uses to get the
|
||||
orchestrator its key and the gateway its token, instead of re-deriving that
|
||||
wiring per backend (the bug class behind PR #471 — see
|
||||
`docs/prds/prd-new-control-plane-auth-provisioning.md`).
|
||||
|
||||
Stdlib-only; the crypto lives in `orchestrator_auth` (untouched HMAC), the key
|
||||
file lives in `paths` (no bot-bottle imports, safe to copy flat), and this module
|
||||
composes the two into the provisioning seam.
|
||||
Stdlib-only: the HMAC lives in `orchestrator_auth`, the key file in `paths`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -49,15 +45,14 @@ class ProvisioningError(RuntimeError):
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TrustDomain:
|
||||
"""One credential boundary: a host-canonical signing key + the role set it
|
||||
signs + the env vars its key and a pre-minted token ride in.
|
||||
"""One service's signing material: a host-canonical key file, the roles that
|
||||
key may sign, and the env vars its key and a minted token ride in.
|
||||
|
||||
`signing_key()` reads-or-mints the host-canonical key (never a guest's); the
|
||||
orchestrator process that *owns* the domain receives that raw key (via
|
||||
`key_env`), while a delegate (the data plane) receives only a pre-minted,
|
||||
role-scoped token (via `token_env`) it cannot rewrite. `mint`/`verify` are
|
||||
scoped to this domain's `roles`, so a token minted here neither carries nor
|
||||
verifies a role from another domain."""
|
||||
The service that *owns* the domain (e.g. the orchestrator) receives the raw
|
||||
key via `key_env`; a delegate (e.g. the gateway) receives only a pre-minted,
|
||||
role-scoped token via `token_env` it cannot rewrite. `mint`/`verify` are
|
||||
scoped to `roles`, so this service's key can neither sign nor accept another
|
||||
service's role."""
|
||||
|
||||
name: str
|
||||
key_filename: str
|
||||
@@ -66,37 +61,35 @@ class TrustDomain:
|
||||
token_env: str
|
||||
|
||||
def signing_key(self) -> str:
|
||||
"""The host-canonical signing key for this domain (minted 0600 on first
|
||||
use). Host-side only — the value is injected into the owning process, not
|
||||
read there."""
|
||||
"""This service's host-canonical signing key (minted 0600 on first use).
|
||||
Host-side only — the value is injected into the owning process."""
|
||||
return host_signing_key(self.key_filename)
|
||||
|
||||
def key_from_env(self, environ: Mapping[str, str] | None = None) -> str:
|
||||
"""The signing key as seen by the *owning process* — read from `key_env`
|
||||
in the environment (default `os.environ`). "" when unset: the caller
|
||||
decides whether that is fatal (see `OrchestratorServer`'s open-mode
|
||||
fallback) or fail-closed (see `ControlPlaneProvisioning`)."""
|
||||
"""The signing key as the owning process sees it — read from `key_env`
|
||||
(default `os.environ`). "" when unset; the caller decides whether that is
|
||||
fatal (`ControlPlaneProvisioning`) or the open-mode fallback
|
||||
(`OrchestratorServer`)."""
|
||||
env = os.environ if environ is None else environ
|
||||
return env.get(self.key_env, "").strip()
|
||||
|
||||
def mint(self, role: str) -> str:
|
||||
"""A role-scoped token for a delegate, signed with this domain's key.
|
||||
Raises ValueError for a role outside this domain (mint only what this
|
||||
boundary accepts)."""
|
||||
"""A role-scoped token for a delegate, signed with this service's key.
|
||||
Raises ValueError for a role this service doesn't sign."""
|
||||
if role not in self.roles:
|
||||
raise ValueError(f"role {role!r} is not in trust domain {self.name!r}")
|
||||
return orchestrator_auth.mint(role, self.signing_key(), roles=self.roles)
|
||||
|
||||
def verify(self, token: str, key: str) -> str | None:
|
||||
"""The role `token` carries under `key`, or None. `key` is passed
|
||||
explicitly (not read from the host file) because the verifier — the
|
||||
control-plane process — holds it in `key_env`, not on disk in its guest."""
|
||||
"""The role `token` carries under `key`, or None. `key` is passed in
|
||||
(not read from disk) because the verifier — the control-plane process —
|
||||
holds it in `key_env`, not on disk in its guest."""
|
||||
return orchestrator_auth.verify(token, key, roles=self.roles)
|
||||
|
||||
|
||||
# The orchestrator control-plane domain: the signing key held by the
|
||||
# orchestrator + host CLI, the `gateway` token handed to the data plane, and the
|
||||
# `cli` token the CLI mints for itself.
|
||||
# The orchestrator's domain: the key the orchestrator (and host CLI) holds, the
|
||||
# `gateway` token it mints for the data plane, and the `cli` token the CLI mints
|
||||
# for itself. #468's host controller will add a second, separate domain.
|
||||
CONTROL_PLANE = TrustDomain(
|
||||
name="control-plane",
|
||||
key_filename=ORCHESTRATOR_TOKEN_FILENAME,
|
||||
@@ -108,21 +101,19 @@ CONTROL_PLANE = TrustDomain(
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Topology:
|
||||
"""What a backend *is*, for control-plane auth provisioning (#476) — the
|
||||
backend declares this instead of encoding the provisioning decision by hand
|
||||
in its launcher.
|
||||
"""Where a backend runs the two planes, so the provisioning seam can decide
|
||||
whether an open control plane is dangerous — the backend declares this
|
||||
instead of hardcoding the decision in its launcher.
|
||||
|
||||
`data_plane_shares_control_host` — the data plane runs on the same host/VM as
|
||||
the control plane, so a reachable-but-OPEN control plane would hand that
|
||||
co-located data plane full `cli`. This is the default and the case that makes
|
||||
the signing key **mandatory** (open mode unreachable). Every current backend
|
||||
is co-located (docker/macOS: two containers on one host; firecracker: two VMs
|
||||
on one host, agents L3-isolated). A backend with a genuinely isolated control
|
||||
`data_plane_shares_control_host` — the gateway runs on the same host/VM as
|
||||
the orchestrator, so an open orchestrator would hand the co-located gateway
|
||||
full `cli`. The default, and what makes the signing key mandatory. Every
|
||||
current backend is co-located (docker/macOS: two containers on one host;
|
||||
firecracker: two VMs on one host, agents L3-isolated); an isolated control
|
||||
plane on a separate trusted host may declare False.
|
||||
|
||||
`combined_guest` — control plane and data plane share a single guest (the
|
||||
retired combined infra VM). Informational today; kept so a future combined
|
||||
backend *declares* it rather than rediscovering the provisioning."""
|
||||
`combined_guest` — both planes in one guest (the retired combined infra VM).
|
||||
Informational; kept so a future combined backend declares it."""
|
||||
|
||||
data_plane_shares_control_host: bool = True
|
||||
combined_guest: bool = False
|
||||
@@ -135,47 +126,34 @@ COLOCATED = Topology(data_plane_shares_control_host=True)
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ControlPlaneProvisioning:
|
||||
"""The single shared control-plane auth provisioning contract (#476).
|
||||
|
||||
A backend launcher satisfies THIS instead of re-deriving the four invariants
|
||||
that each took a PR #471 review round to get right:
|
||||
|
||||
1. **Host-canonical key.** The signing key is `domain.signing_key()` — a
|
||||
guest is *handed* it, never generates or overwrites it (round 3's bug:
|
||||
the Firecracker guest clobbered the host key).
|
||||
2. **Split credential.** Only the orchestrator process gets the raw key
|
||||
(`orchestrator_key`); the data plane gets a pre-minted, role-scoped
|
||||
token (`gateway_token`) it cannot rewrite into `cli` (round 1's bug).
|
||||
3. **CLI validity across backends.** The host CLI mints its `cli` token
|
||||
from this same canonical key, so it stays valid no matter which backend
|
||||
(or how many) are co-running.
|
||||
4. **No open mode.** `orchestrator_key` fail-closes for any topology whose
|
||||
data plane shares the control plane's host/VM, so a launcher cannot
|
||||
start the control plane OPEN (round 2's bug)."""
|
||||
"""The one seam every backend launcher uses to provision control-plane auth,
|
||||
instead of re-deriving the four invariants that each cost a PR #471 review
|
||||
round: the orchestrator gets the raw key (`orchestrator_key`), the gateway
|
||||
gets a minted `gateway` token (`gateway_token`), the host CLI mints its own
|
||||
`cli` token from the same host-canonical key, and the orchestrator never
|
||||
starts open where its gateway is co-located."""
|
||||
|
||||
domain: TrustDomain = CONTROL_PLANE
|
||||
topology: Topology = field(default=COLOCATED)
|
||||
|
||||
def orchestrator_key(self) -> str:
|
||||
"""The raw signing key the control-plane *process* must receive (carry it
|
||||
in `domain.key_env`). Fail-closed: raises `ProvisioningError` rather than
|
||||
returning "" for a co-located topology, because an empty key makes the
|
||||
server run OPEN and hand the co-located data plane full `cli` (#476
|
||||
invariant 4)."""
|
||||
"""The raw signing key the orchestrator process must receive (carry it in
|
||||
`domain.key_env`). Fail-closed: raises rather than return "" for a
|
||||
co-located topology, since an empty key runs the server open and hands
|
||||
the co-located gateway full `cli`."""
|
||||
key = self.domain.signing_key()
|
||||
if not key and self.topology.data_plane_shares_control_host:
|
||||
raise ProvisioningError(
|
||||
f"refusing to provision the {self.domain.name} control plane "
|
||||
"without a signing key: its data plane shares this host/VM, so "
|
||||
"an OPEN control plane would grant that data plane full `cli` "
|
||||
"(#476)"
|
||||
f"refusing to start the {self.domain.name} orchestrator without "
|
||||
"a signing key: its gateway shares this host/VM, so an open "
|
||||
"orchestrator would grant that gateway full `cli` (#476)"
|
||||
)
|
||||
return key
|
||||
|
||||
def gateway_token(self) -> str:
|
||||
"""The pre-minted `gateway`-role token the data plane receives (carry it
|
||||
in `domain.token_env`) — minted from the canonical key, never the key
|
||||
itself, so a compromised data plane cannot forge a `cli` token."""
|
||||
"""The `gateway`-role token the gateway receives (carry it in
|
||||
`domain.token_env`) — minted from the key, never the key itself, so a
|
||||
compromised gateway cannot forge a `cli` token."""
|
||||
return self.domain.mint(ROLE_GATEWAY)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user