From 45f3cefbc52db63e805a45046381165d695763b5 Mon Sep 17 00:00:00 2001 From: claude Date: Sun, 26 Jul 2026 00:41:44 +0000 Subject: [PATCH 1/3] refactor(orchestrator): uniform control-plane auth provisioning per trust domain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Closes #476 --- bot_bottle/backend/docker/orchestrator.py | 5 +- .../backend/firecracker/orchestrator.py | 11 +- .../backend/macos_container/orchestrator.py | 9 +- bot_bottle/orchestrator/client.py | 6 +- bot_bottle/orchestrator/lifecycle.py | 25 ++- bot_bottle/orchestrator/server.py | 12 +- bot_bottle/orchestrator_auth.py | 22 +- bot_bottle/paths.py | 30 ++- bot_bottle/trust_domain.py | 189 ++++++++++++++++++ ...prd-new-control-plane-auth-provisioning.md | 141 +++++++++++++ tests/unit/test_docker_orchestrator.py | 4 +- tests/unit/test_firecracker_orchestrator.py | 2 +- tests/unit/test_macos_orchestrator.py | 4 +- tests/unit/test_orchestrator_auth.py | 21 ++ tests/unit/test_orchestrator_client.py | 6 +- tests/unit/test_trust_domain.py | 113 +++++++++++ 16 files changed, 553 insertions(+), 47 deletions(-) create mode 100644 bot_bottle/trust_domain.py create mode 100644 docs/prds/prd-new-control-plane-auth-provisioning.md create mode 100644 tests/unit/test_trust_domain.py diff --git a/bot_bottle/backend/docker/orchestrator.py b/bot_bottle/backend/docker/orchestrator.py index 550d99ea..6c8c45ad 100644 --- a/bot_bottle/backend/docker/orchestrator.py +++ b/bot_bottle/backend/docker/orchestrator.py @@ -19,7 +19,6 @@ from .util import run_docker from ...paths import ( ORCHESTRATOR_TOKEN_ENV, bot_bottle_root, - host_orchestrator_token, ) from ...gateway import GatewayError from ...orchestrator.lifecycle import ( @@ -171,7 +170,9 @@ class DockerOrchestrator(Orchestrator): fixed-name container first).""" self._ensure_control_network() run_docker(["docker", "rm", "--force", self.name]) - _signing_key = host_orchestrator_token() + # The signing key comes through the shared provisioning contract (#476), + # which fail-closes rather than yield an empty key that would run OPEN. + _signing_key = self.control_plane_key() proc = run_docker([ "docker", "run", "--detach", "--name", self.name, diff --git a/bot_bottle/backend/firecracker/orchestrator.py b/bot_bottle/backend/firecracker/orchestrator.py index 4b79808f..f2d17157 100644 --- a/bot_bottle/backend/firecracker/orchestrator.py +++ b/bot_bottle/backend/firecracker/orchestrator.py @@ -21,7 +21,6 @@ import time from pathlib import Path from ...log import die, info -from ...paths import host_orchestrator_token from ...orchestrator.lifecycle import ( DEFAULT_STARTUP_TIMEOUT_SECONDS, Orchestrator, @@ -108,11 +107,13 @@ class FirecrackerOrchestrator(Orchestrator): data_drive=self._ensure_registry_volume(), ) # Push the host-canonical signing key (the init waits for it before - # starting the control plane). The host token file stays the single - # source of truth, so a co-running docker/macOS control plane keeps - # working; the guest verifies tokens with the same key the CLI signs from. + # starting the control plane). It comes through the shared provisioning + # contract (#476) — the same host token file every backend uses, so a + # co-running docker/macOS control plane keeps working and the guest + # verifies tokens with the same key the CLI signs from; fail-closed, so + # the guest is never handed an empty key that would run it OPEN. infra_vm.push_secret( - vm, host_orchestrator_token(), infra_vm._GUEST_SIGNING_KEY_PATH, + vm, self.control_plane_key(), infra_vm._GUEST_SIGNING_KEY_PATH, "the control-plane signing key to the orchestrator VM " "(its control plane will not start)", ) diff --git a/bot_bottle/backend/macos_container/orchestrator.py b/bot_bottle/backend/macos_container/orchestrator.py index 32802760..3831e5ba 100644 --- a/bot_bottle/backend/macos_container/orchestrator.py +++ b/bot_bottle/backend/macos_container/orchestrator.py @@ -18,10 +18,7 @@ import urllib.request from pathlib import Path from ... import log -from ...paths import ( - ORCHESTRATOR_TOKEN_ENV, - host_orchestrator_token, -) +from ...paths import ORCHESTRATOR_TOKEN_ENV from ...orchestrator.lifecycle import ( DEFAULT_HEALTH_TIMEOUT_SECONDS, DEFAULT_PORT, @@ -134,7 +131,9 @@ class MacosOrchestrator(Orchestrator): def _run_container(self, current_hash: str) -> None: container_mod.force_remove_container(self.name) - _signing_key = host_orchestrator_token() + # The signing key comes through the shared provisioning contract (#476), + # which fail-closes rather than yield an empty key that would run OPEN. + _signing_key = self.control_plane_key() argv = [ "container", "run", "--detach", "--name", self.name, diff --git a/bot_bottle/orchestrator/client.py b/bot_bottle/orchestrator/client.py index 96ef47bd..9d51a5a5 100644 --- a/bot_bottle/orchestrator/client.py +++ b/bot_bottle/orchestrator/client.py @@ -18,8 +18,8 @@ import urllib.request from collections.abc import Iterable from dataclasses import dataclass -from ..orchestrator_auth import ROLE_CLI, mint -from ..paths import host_orchestrator_token +from ..orchestrator_auth import ROLE_CLI +from ..trust_domain import CONTROL_PLANE from .server import ORCHESTRATOR_AUTH_HEADER DEFAULT_TIMEOUT_SECONDS = 5.0 @@ -32,7 +32,7 @@ def _host_auth_token() -> str: "" means 'send no auth header' — correct against an open (unconfigured) control plane, and harmlessly rejected by a secured one.""" try: - return mint(ROLE_CLI, host_orchestrator_token()) + return CONTROL_PLANE.mint(ROLE_CLI) except (OSError, ValueError): return "" diff --git a/bot_bottle/orchestrator/lifecycle.py b/bot_bottle/orchestrator/lifecycle.py index 22b67988..705a540d 100644 --- a/bot_bottle/orchestrator/lifecycle.py +++ b/bot_bottle/orchestrator/lifecycle.py @@ -21,8 +21,7 @@ import urllib.error import urllib.request from pathlib import Path -from ..orchestrator_auth import ROLE_GATEWAY, mint -from ..paths import host_orchestrator_token +from ..trust_domain import ControlPlaneProvisioning DEFAULT_PORT = 8099 DEFAULT_STARTUP_TIMEOUT_SECONDS = 45.0 @@ -58,6 +57,13 @@ class Orchestrator(abc.ABC): so it — not the gateway — mints the gateway's role-scoped token. Backend-neutral.""" + # The shared control-plane auth provisioning contract (#476). Every backend + # gets its signing key + gateway token through this one seam rather than + # re-deriving the wiring; the default topology is co-located + fail-closed, + # so a backend need not redeclare it (a truly isolated control plane would + # override this with a different `Topology`). + provisioning: ControlPlaneProvisioning = ControlPlaneProvisioning() + def ensure_built(self) -> None: """Ensure the orchestrator's image / rootfs exists, building it if needed. Default: nothing to build (e.g. a pre-pulled image). Call before @@ -105,9 +111,17 @@ class Orchestrator(abc.ABC): def mint_gateway_token(self) -> str: """Mint a role-scoped `gateway` JWT from the host signing key for the gateway to present. The orchestrator holds the key; the gateway never - does (#469). Backend-neutral — the same host token file is the single - source of truth across backends.""" - return mint(ROLE_GATEWAY, host_orchestrator_token()) + does (#469). Routed through the shared provisioning contract (#476), so + the same host token file is the single source of truth across backends.""" + return self.provisioning.gateway_token() + + def control_plane_key(self) -> str: + """The raw signing key the control-plane *process* must receive — the ONE + place a backend obtains it (docker/macOS inject it as `key_env`; + firecracker pushes it to the guest). Fail-closed via the provisioning + contract: it raises rather than yield an empty key that would run the + server OPEN (#476).""" + return self.provisioning.orchestrator_key() __all__ = [ @@ -117,4 +131,5 @@ __all__ = [ "OrchestratorStartError", "source_hash", "Orchestrator", + "ControlPlaneProvisioning", ] diff --git a/bot_bottle/orchestrator/server.py b/bot_bottle/orchestrator/server.py index dbaecd61..a18dac3f 100644 --- a/bot_bottle/orchestrator/server.py +++ b/bot_bottle/orchestrator/server.py @@ -63,8 +63,8 @@ import sys import typing from urllib.parse import urlsplit -from ..orchestrator_auth import ROLE_CLI, ROLES, verify -from ..paths import ORCHESTRATOR_TOKEN_ENV +from ..orchestrator_auth import ROLE_CLI, ROLES +from ..trust_domain import CONTROL_PLANE from ..supervisor.types import TOOLS from .service import OrchestratorCore @@ -413,11 +413,13 @@ class OrchestratorServer(socketserver.ThreadingMixIn, http.server.HTTPServer): def __init__(self, address: tuple[str, int], orchestrator: OrchestratorCore) -> None: self.orchestrator = orchestrator - self._signing_key = os.environ.get(ORCHESTRATOR_TOKEN_ENV, "").strip() + # The control-plane trust domain's signing key, as injected into THIS + # (the owning) process by the launcher (#476). Unset → open mode below. + self._signing_key = CONTROL_PLANE.key_from_env() if not self._signing_key: sys.stderr.write( "orchestrator: WARNING — no control-plane signing key " - f"(${ORCHESTRATOR_TOKEN_ENV}); running WITHOUT caller " + f"(${CONTROL_PLANE.key_env}); running WITHOUT caller " "authentication. Any client that can reach this port can drive " "it. Backends that put the control plane on an agent-reachable " "network MUST set this.\n" @@ -433,7 +435,7 @@ class OrchestratorServer(socketserver.ThreadingMixIn, http.server.HTTPServer): role (→ per-route 401/403 in `dispatch`).""" if not self._signing_key: return ROLE_CLI - return verify(presented, self._signing_key) + return CONTROL_PLANE.verify(presented, self._signing_key) def make_server( diff --git a/bot_bottle/orchestrator_auth.py b/bot_bottle/orchestrator_auth.py index 54f47620..5b07e40e 100644 --- a/bot_bottle/orchestrator_auth.py +++ b/bot_bottle/orchestrator_auth.py @@ -59,12 +59,19 @@ _HEADER_SEGMENT = _b64url_encode( ) -def mint(role: str, secret: str) -> str: +def mint(role: str, secret: str, *, roles: frozenset[str] = ROLES) -> str: """A compact HS256 token asserting `role`, signed with `secret`. - Raises ValueError for an unknown role (mint only what the control plane will + `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). + + 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).""" - if role not in ROLES: + if role not in roles: raise ValueError(f"unknown control-plane role {role!r}") if not secret: raise ValueError("cannot mint a control-plane token without a signing key") @@ -73,10 +80,11 @@ def mint(role: str, secret: str) -> str: return f"{signing_input}.{_sign(secret, signing_input)}" -def verify(token: str, secret: str) -> str | None: +def verify(token: str, secret: str, *, roles: frozenset[str] = ROLES) -> str | None: """The role a valid `token` carries, or None if it is malformed, wrongly - signed, or names an unknown role. Constant-time signature check; rejects any - header whose alg isn't HS256 (no alg-confusion / `none`).""" + signed, or names a role outside `roles` (the verifying trust domain's set — + default `{gateway, cli}`). Constant-time signature check; rejects any header + whose alg isn't HS256 (no alg-confusion / `none`).""" if not token or not secret: return None parts = token.split(".") @@ -94,7 +102,7 @@ def verify(token: str, secret: str) -> str | None: if not isinstance(header, dict) or header.get("alg") != _ALG: return None role = payload.get("role") if isinstance(payload, dict) else None - return role if isinstance(role, str) and role in ROLES else None + return role if isinstance(role, str) and role in roles else None __all__ = ["ROLE_GATEWAY", "ROLE_CLI", "ROLES", "mint", "verify"] diff --git a/bot_bottle/paths.py b/bot_bottle/paths.py index 7b991cbf..7f7841e4 100644 --- a/bot_bottle/paths.py +++ b/bot_bottle/paths.py @@ -97,16 +97,19 @@ def host_gateway_ca_dir() -> Path: return ca_dir -def host_orchestrator_token() -> str: - """The per-host control-plane secret, minted (256-bit, url-safe) and - persisted 0600 on first use, then reused. +def host_signing_key(filename: str) -> str: + """A per-host signing key at `/`, minted (256-bit, url-safe) + and persisted 0600 on first use, then reused. - This is the shared secret the launchers inject into the control-plane and - gateway containers and that the host CLI presents on every call. It is a - *host* artifact — the file lives under the root the agent never mounts, and - the env var is set only on the trusted containers — so reading it here is - safe on the host launch path but the value never reaches a bottle.""" - path = bot_bottle_root() / ORCHESTRATOR_TOKEN_FILENAME + 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.""" + path = bot_bottle_root() / filename try: existing = path.read_text().strip() if existing: @@ -128,6 +131,14 @@ def host_orchestrator_token() -> str: return token +def host_orchestrator_token() -> str: + """The per-host control-plane signing key — the host-canonical key the + launchers inject into the control-plane process and the host CLI mints its + own `cli` token from. The `control-plane` trust domain's specialization of + `host_signing_key()`.""" + return host_signing_key(ORCHESTRATOR_TOKEN_FILENAME) + + __all__ = [ "HOST_DB_FILENAME", "ORCHESTRATOR_TOKEN_FILENAME", @@ -138,5 +149,6 @@ __all__ = [ "host_db_path", "host_db_dir", "host_gateway_ca_dir", + "host_signing_key", "host_orchestrator_token", ] diff --git a/bot_bottle/trust_domain.py b/bot_bottle/trust_domain.py new file mode 100644 index 00000000..66d64c2e --- /dev/null +++ b/bot_bottle/trust_domain.py @@ -0,0 +1,189 @@ +"""Trust domains: the unit of control-plane auth provisioning (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. + +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. + +`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 +`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. +""" + +from __future__ import annotations + +import os +from collections.abc import Mapping +from dataclasses import dataclass, field + +from . import orchestrator_auth +from .orchestrator_auth import ROLE_GATEWAY +from .paths import ( + ORCHESTRATOR_AUTH_JWT_ENV, + ORCHESTRATOR_TOKEN_ENV, + ORCHESTRATOR_TOKEN_FILENAME, + host_signing_key, +) + + +class ProvisioningError(RuntimeError): + """A control-plane auth invariant would be violated (e.g. starting a + co-located control plane without its signing key — which would run OPEN).""" + + +@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. + + `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.""" + + name: str + key_filename: str + roles: frozenset[str] + key_env: str + 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.""" + 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`).""" + 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).""" + 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.""" + 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. +CONTROL_PLANE = TrustDomain( + name="control-plane", + key_filename=ORCHESTRATOR_TOKEN_FILENAME, + roles=orchestrator_auth.ROLES, + key_env=ORCHESTRATOR_TOKEN_ENV, + token_env=ORCHESTRATOR_AUTH_JWT_ENV, +) + + +@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. + + `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 + 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.""" + + data_plane_shares_control_host: bool = True + combined_guest: bool = False + + +# The default posture: data plane co-located with the control plane. Fail-closed +# — the signing key is mandatory. A backend opts out only by declaring otherwise. +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).""" + + 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).""" + 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)" + ) + 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.""" + return self.domain.mint(ROLE_GATEWAY) + + +__all__ = [ + "ProvisioningError", + "TrustDomain", + "CONTROL_PLANE", + "Topology", + "COLOCATED", + "ControlPlaneProvisioning", +] diff --git a/docs/prds/prd-new-control-plane-auth-provisioning.md b/docs/prds/prd-new-control-plane-auth-provisioning.md new file mode 100644 index 00000000..da80ef9c --- /dev/null +++ b/docs/prds/prd-new-control-plane-auth-provisioning.md @@ -0,0 +1,141 @@ +# 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. diff --git a/tests/unit/test_docker_orchestrator.py b/tests/unit/test_docker_orchestrator.py index d94ff52a..fea4e845 100644 --- a/tests/unit/test_docker_orchestrator.py +++ b/tests/unit/test_docker_orchestrator.py @@ -19,7 +19,9 @@ _ORCH = "bot_bottle.backend.docker.orchestrator" _RUN = f"{_ORCH}.run_docker" _SLEEP = f"{_ORCH}.time.sleep" _MONOTONIC = f"{_ORCH}.time.monotonic" -_TOKEN = f"{_ORCH}.host_orchestrator_token" +# The signing key is read through the shared provisioning contract (#476); patch +# its host-canonical key file read to keep it off the real host file. +_TOKEN = "bot_bottle.trust_domain.host_signing_key" # The ABC's is_healthy probes /health via urllib in the lifecycle module. _URLOPEN = "bot_bottle.orchestrator.lifecycle.urllib.request.urlopen" diff --git a/tests/unit/test_firecracker_orchestrator.py b/tests/unit/test_firecracker_orchestrator.py index 28fa2c43..5e1524ba 100644 --- a/tests/unit/test_firecracker_orchestrator.py +++ b/tests/unit/test_firecracker_orchestrator.py @@ -44,7 +44,7 @@ class TestEnsureRunning(unittest.TestCase): vm = infra_vm.InfraVm(guest_ip="10.243.255.1", private_key=Path("/k"), vm=MagicMock()) with patch.object(infra_vm, "boot_vm", return_value=vm) as boot, \ patch.object(infra_vm, "push_secret") as push, \ - patch(f"{_ORCH}.host_orchestrator_token", return_value="host-key"), \ + patch("bot_bottle.trust_domain.host_signing_key", return_value="host-key"), \ patch.object(orch, "is_running", return_value=False), \ patch.object(orch, "_ensure_registry_volume", return_value=Path("/reg")), \ patch.object(orch, "_wait_for_health"): diff --git a/tests/unit/test_macos_orchestrator.py b/tests/unit/test_macos_orchestrator.py index 845c099d..11fad373 100644 --- a/tests/unit/test_macos_orchestrator.py +++ b/tests/unit/test_macos_orchestrator.py @@ -33,7 +33,7 @@ class TestMacosOrchestratorRun(unittest.TestCase): def _run(self) -> list[str]: run = Mock(return_value=_ok()) with patch(f"{_ORCH}.container_mod") as mod, \ - patch(f"{_ORCH}.host_orchestrator_token", return_value="k"): + patch("bot_bottle.trust_domain.host_signing_key", return_value="k"): mod.dns_server.return_value = "1.1.1.1" mod.bind_mount_spec.side_effect = _spec mod.run_container_argv = run @@ -65,7 +65,7 @@ class TestMacosOrchestratorRun(unittest.TestCase): def test_start_failure_raises(self) -> None: with patch(f"{_ORCH}.container_mod") as mod, \ - patch(f"{_ORCH}.host_orchestrator_token", return_value="k"): + patch("bot_bottle.trust_domain.host_signing_key", return_value="k"): mod.dns_server.return_value = "1.1.1.1" mod.run_container_argv = Mock(return_value=_fail()) with self.assertRaises(OrchestratorStartError): diff --git a/tests/unit/test_orchestrator_auth.py b/tests/unit/test_orchestrator_auth.py index 78e25596..ea9880f2 100644 --- a/tests/unit/test_orchestrator_auth.py +++ b/tests/unit/test_orchestrator_auth.py @@ -82,5 +82,26 @@ class TestMintVerify(unittest.TestCase): mint(ROLE_GATEWAY, "") +class TestCustomRoleSet(unittest.TestCase): + """The `roles=` seam a trust domain other than the control plane uses (#476): + mint/verify are scoped to the passed role set, not the module default.""" + + _ROLES = frozenset({"host"}) + + def test_round_trips_a_role_in_the_custom_set(self) -> None: + tok = mint("host", _KEY, roles=self._ROLES) + self.assertEqual("host", verify(tok, _KEY, roles=self._ROLES)) + + def test_mint_rejects_a_role_outside_the_custom_set(self) -> None: + with self.assertRaises(ValueError): + mint(ROLE_CLI, _KEY, roles=self._ROLES) + + def test_verify_rejects_a_role_outside_the_verifiers_set(self) -> None: + # A validly signed token whose role isn't in the verifier's set fails — + # this is what keeps one domain's key from asserting another's role. + tok = mint(ROLE_CLI, _KEY) # a control-plane `cli` token + self.assertIsNone(verify(tok, _KEY, roles=self._ROLES)) + + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_orchestrator_client.py b/tests/unit/test_orchestrator_client.py index a9f77da2..aa04a3e4 100644 --- a/tests/unit/test_orchestrator_client.py +++ b/tests/unit/test_orchestrator_client.py @@ -20,13 +20,15 @@ _URLOPEN = "bot_bottle.orchestrator.client.urllib.request.urlopen" class TestHostAuthToken(unittest.TestCase): def test_mints_a_cli_token_from_the_host_key(self) -> None: - with patch("bot_bottle.orchestrator.client.host_orchestrator_token", + # The CLI mints its `cli` token from the control-plane trust domain's + # host-canonical key (#476) — patch the key file read underneath it. + with patch("bot_bottle.trust_domain.host_signing_key", return_value="signing-key"): tok = _host_auth_token() self.assertEqual(ROLE_CLI, verify(tok, "signing-key")) def test_returns_empty_when_key_unreadable(self) -> None: - with patch("bot_bottle.orchestrator.client.host_orchestrator_token", + with patch("bot_bottle.trust_domain.host_signing_key", side_effect=OSError("no host root")): self.assertEqual("", _host_auth_token()) diff --git a/tests/unit/test_trust_domain.py b/tests/unit/test_trust_domain.py new file mode 100644 index 00000000..c8d775c8 --- /dev/null +++ b/tests/unit/test_trust_domain.py @@ -0,0 +1,113 @@ +"""Unit: trust domains + the shared control-plane provisioning contract (#476).""" + +from __future__ import annotations + +import unittest +from unittest.mock import patch + +from bot_bottle import orchestrator_auth +from bot_bottle.orchestrator_auth import ROLE_CLI, ROLE_GATEWAY +from bot_bottle.trust_domain import ( + CONTROL_PLANE, + ControlPlaneProvisioning, + ProvisioningError, + Topology, + TrustDomain, +) + +# A second, unrelated domain — the shape #468's host controller would take: its +# own key file, its own role, its own env vars. Distinct from CONTROL_PLANE. +_HOST_CTRL = TrustDomain( + name="host-controller", + key_filename="host-controller-token", + roles=frozenset({"host"}), + key_env="BOT_BOTTLE_HOST_CONTROLLER_TOKEN", + token_env="BOT_BOTTLE_HOST_CONTROLLER_JWT", +) + + +class TestTrustDomainMintVerify(unittest.TestCase): + def test_mint_verify_round_trips_within_a_domain(self) -> None: + with patch("bot_bottle.trust_domain.host_signing_key", return_value="k"): + tok = CONTROL_PLANE.mint(ROLE_GATEWAY) + self.assertEqual(ROLE_GATEWAY, CONTROL_PLANE.verify(tok, "k")) + + def test_mint_rejects_a_role_outside_the_domain(self) -> None: + # `host` is a valid role in _HOST_CTRL but not in the control plane — + # the control-plane key must refuse to mint it. + with patch("bot_bottle.trust_domain.host_signing_key", return_value="k"): + with self.assertRaises(ValueError): + CONTROL_PLANE.mint("host") + + def test_a_custom_role_set_verifies_its_own_role(self) -> None: + with patch("bot_bottle.trust_domain.host_signing_key", return_value="k"): + tok = _HOST_CTRL.mint("host") + self.assertEqual("host", _HOST_CTRL.verify(tok, "k")) + + def test_key_from_env_reads_the_domains_env_var(self) -> None: + self.assertEqual( + "secret", CONTROL_PLANE.key_from_env({CONTROL_PLANE.key_env: " secret "})) + self.assertEqual("", CONTROL_PLANE.key_from_env({})) + + +class TestDomainBoundary(unittest.TestCase): + """The #476/#468 invariant: two domains, two keys, two role sets — one + domain's key can neither mint nor verify the other's tokens.""" + + def test_a_control_plane_token_does_not_verify_under_another_domains_key( + self, + ) -> None: + # Even with an IDENTICAL underlying key, a `cli` token minted under the + # control-plane domain must not verify as a role in the host-controller + # domain — the role isn't in that domain's set. + cli_tok = orchestrator_auth.mint(ROLE_CLI, "shared-bytes") + self.assertIsNone(_HOST_CTRL.verify(cli_tok, "shared-bytes")) + + def test_distinct_keys_do_not_cross_verify(self) -> None: + # The realistic case: distinct host-canonical keys per domain. A token + # signed by one key never verifies under the other. + host_tok = orchestrator_auth.mint( + "host", "host-ctrl-key", roles=_HOST_CTRL.roles) + self.assertIsNone(_HOST_CTRL.verify(host_tok, "control-plane-key")) + self.assertEqual("host", _HOST_CTRL.verify(host_tok, "host-ctrl-key")) + + +class TestControlPlaneProvisioning(unittest.TestCase): + def test_orchestrator_key_returns_the_canonical_key(self) -> None: + prov = ControlPlaneProvisioning() + with patch("bot_bottle.trust_domain.host_signing_key", return_value="key"): + self.assertEqual("key", prov.orchestrator_key()) + + def test_orchestrator_key_fail_closes_when_colocated_and_empty(self) -> None: + # Invariant 4: a co-located backend must never start the control plane + # without a key (it would run OPEN and hand the co-located data plane + # full `cli`). + prov = ControlPlaneProvisioning() # default topology: co-located + with patch("bot_bottle.trust_domain.host_signing_key", return_value=""): + with self.assertRaises(ProvisioningError): + prov.orchestrator_key() + + def test_orchestrator_key_allows_empty_when_isolated(self) -> None: + # A genuinely isolated control plane (no co-located data plane) may run + # without a key — the only topology where open mode is permissible. + prov = ControlPlaneProvisioning( + topology=Topology(data_plane_shares_control_host=False)) + with patch("bot_bottle.trust_domain.host_signing_key", return_value=""): + self.assertEqual("", prov.orchestrator_key()) + + def test_gateway_token_is_a_verifiable_gateway_role_token(self) -> None: + prov = ControlPlaneProvisioning() + with patch("bot_bottle.trust_domain.host_signing_key", return_value="k"): + tok = prov.gateway_token() + self.assertEqual(ROLE_GATEWAY, CONTROL_PLANE.verify(tok, "k")) + + def test_gateway_token_cannot_be_reused_as_cli(self) -> None: + # The data plane's token is `gateway`-scoped: it never carries `cli`. + prov = ControlPlaneProvisioning() + with patch("bot_bottle.trust_domain.host_signing_key", return_value="k"): + tok = prov.gateway_token() + self.assertNotEqual(ROLE_CLI, CONTROL_PLANE.verify(tok, "k")) + + +if __name__ == "__main__": + unittest.main() -- 2.52.0 From 8f6148d571aeb31ac6b8e7fea870caea5555c42d Mon Sep 17 00:00:00 2001 From: claude Date: Sun, 26 Jul 2026 01:14:06 +0000 Subject: [PATCH 2/3] docs(orchestrator): tighten auth-provisioning wording to concrete services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- bot_bottle/orchestrator_auth.py | 14 +- bot_bottle/paths.py | 14 +- bot_bottle/trust_domain.py | 150 +++++++-------- ...prd-new-control-plane-auth-provisioning.md | 175 ++++++------------ 4 files changed, 137 insertions(+), 216 deletions(-) diff --git a/bot_bottle/orchestrator_auth.py b/bot_bottle/orchestrator_auth.py index 5b07e40e..687dabff 100644 --- a/bot_bottle/orchestrator_auth.py +++ b/bot_bottle/orchestrator_auth.py @@ -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: diff --git a/bot_bottle/paths.py b/bot_bottle/paths.py index 7f7841e4..8cd69e04 100644 --- a/bot_bottle/paths.py +++ b/bot_bottle/paths.py @@ -101,14 +101,12 @@ def host_signing_key(filename: str) -> str: """A per-host signing key at `/`, 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() diff --git a/bot_bottle/trust_domain.py b/bot_bottle/trust_domain.py index 66d64c2e..428df119 100644 --- a/bot_bottle/trust_domain.py +++ b/bot_bottle/trust_domain.py @@ -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) diff --git a/docs/prds/prd-new-control-plane-auth-provisioning.md b/docs/prds/prd-new-control-plane-auth-provisioning.md index da80ef9c..865cd396 100644 --- a/docs/prds/prd-new-control-plane-auth-provisioning.md +++ b/docs/prds/prd-new-control-plane-auth-provisioning.md @@ -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. -- 2.52.0 From e53104d5c1b34d5178016a3ac7d80585c3dd1228 Mon Sep 17 00:00:00 2001 From: didericis-claude Date: Sun, 26 Jul 2026 01:40:19 +0000 Subject: [PATCH 3/3] refactor(orchestrator): fail closed unconditionally, drop topology opt-out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the empty-key opt-out codex flagged: ControlPlaneProvisioning no longer lets the orchestrator start without a signing key when a backend declares an "isolated" topology. Host separation is not the safety condition — the gateway (and any other caller) must reach the control-plane listener for /resolve, so an open orchestrator would still grant them full `cli`. The signing key is now mandatory for every backend. Drops the now-purposeless Topology/COLOCATED machinery (no backend declared a non-default topology) and the contractual test_orchestrator_key_allows_empty_when_isolated. Updates the PRD's invariant 4 and lifecycle docstring accordingly. Docs/behaviour only otherwise. Co-Authored-By: Claude Opus 4.8 --- bot_bottle/orchestrator/lifecycle.py | 5 +- bot_bottle/trust_domain.py | 49 +++++-------------- ...prd-new-control-plane-auth-provisioning.md | 9 ++-- tests/unit/test_trust_domain.py | 20 +++----- 4 files changed, 24 insertions(+), 59 deletions(-) diff --git a/bot_bottle/orchestrator/lifecycle.py b/bot_bottle/orchestrator/lifecycle.py index 705a540d..01e722ad 100644 --- a/bot_bottle/orchestrator/lifecycle.py +++ b/bot_bottle/orchestrator/lifecycle.py @@ -59,9 +59,8 @@ class Orchestrator(abc.ABC): # The shared control-plane auth provisioning contract (#476). Every backend # gets its signing key + gateway token through this one seam rather than - # re-deriving the wiring; the default topology is co-located + fail-closed, - # so a backend need not redeclare it (a truly isolated control plane would - # override this with a different `Topology`). + # re-deriving the wiring; it is fail-closed for every backend — the + # orchestrator never starts without its signing key. provisioning: ControlPlaneProvisioning = ControlPlaneProvisioning() def ensure_built(self) -> None: diff --git a/bot_bottle/trust_domain.py b/bot_bottle/trust_domain.py index 428df119..f3274c41 100644 --- a/bot_bottle/trust_domain.py +++ b/bot_bottle/trust_domain.py @@ -26,7 +26,7 @@ from __future__ import annotations import os from collections.abc import Mapping -from dataclasses import dataclass, field +from dataclasses import dataclass from . import orchestrator_auth from .orchestrator_auth import ROLE_GATEWAY @@ -39,8 +39,8 @@ from .paths import ( class ProvisioningError(RuntimeError): - """A control-plane auth invariant would be violated (e.g. starting a - co-located control plane without its signing key — which would run OPEN).""" + """A control-plane auth invariant would be violated (e.g. starting the + orchestrator without its signing key — which would run OPEN).""" @dataclass(frozen=True) @@ -99,31 +99,6 @@ CONTROL_PLANE = TrustDomain( ) -@dataclass(frozen=True) -class Topology: - """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 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` — 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 - - -# The default posture: data plane co-located with the control plane. Fail-closed -# — the signing key is mandatory. A backend opts out only by declaring otherwise. -COLOCATED = Topology(data_plane_shares_control_host=True) - - @dataclass(frozen=True) class ControlPlaneProvisioning: """The one seam every backend launcher uses to provision control-plane auth, @@ -131,22 +106,22 @@ class ControlPlaneProvisioning: 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.""" + starts open.""" domain: TrustDomain = CONTROL_PLANE - topology: Topology = field(default=COLOCATED) def orchestrator_key(self) -> str: """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`.""" + `domain.key_env`). Fail-closed: raises rather than return "", since an + empty key runs the server open — and being on a separate host does not + stop the gateway from reaching the control plane (it must, for + `/resolve`), so an open orchestrator would treat that gateway as `cli`.""" key = self.domain.signing_key() - if not key and self.topology.data_plane_shares_control_host: + if not key: raise ProvisioningError( 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)" + "a signing key: an open orchestrator authenticates no one and " + "grants every caller that reaches it full `cli` (#476)" ) return key @@ -161,7 +136,5 @@ __all__ = [ "ProvisioningError", "TrustDomain", "CONTROL_PLANE", - "Topology", - "COLOCATED", "ControlPlaneProvisioning", ] diff --git a/docs/prds/prd-new-control-plane-auth-provisioning.md b/docs/prds/prd-new-control-plane-auth-provisioning.md index 865cd396..ebd82697 100644 --- a/docs/prds/prd-new-control-plane-auth-provisioning.md +++ b/docs/prds/prd-new-control-plane-auth-provisioning.md @@ -45,7 +45,9 @@ key. 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. + 4. the orchestrator never runs open — the signing key is mandatory, with no + topology opt-out (a separate host does not stop a caller from reaching the + control-plane listener, so it cannot make open mode safe). ## Non-goals @@ -72,9 +74,8 @@ signed by one service's key neither carries nor verifies another service's role. **`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 — +(fail-closed for every backend: it raises rather than hand back an empty key that +would run the server open). `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. diff --git a/tests/unit/test_trust_domain.py b/tests/unit/test_trust_domain.py index c8d775c8..608388fb 100644 --- a/tests/unit/test_trust_domain.py +++ b/tests/unit/test_trust_domain.py @@ -11,7 +11,6 @@ from bot_bottle.trust_domain import ( CONTROL_PLANE, ControlPlaneProvisioning, ProvisioningError, - Topology, TrustDomain, ) @@ -78,23 +77,16 @@ class TestControlPlaneProvisioning(unittest.TestCase): with patch("bot_bottle.trust_domain.host_signing_key", return_value="key"): self.assertEqual("key", prov.orchestrator_key()) - def test_orchestrator_key_fail_closes_when_colocated_and_empty(self) -> None: - # Invariant 4: a co-located backend must never start the control plane - # without a key (it would run OPEN and hand the co-located data plane - # full `cli`). - prov = ControlPlaneProvisioning() # default topology: co-located + def test_orchestrator_key_fail_closes_when_empty(self) -> None: + # Invariant 4: the orchestrator must never start without a key — it would + # run OPEN and grant every caller that reaches it full `cli`. There is no + # topology opt-out: a separate host does not stop the gateway (or any + # other caller) from reaching the control-plane listener. + prov = ControlPlaneProvisioning() with patch("bot_bottle.trust_domain.host_signing_key", return_value=""): with self.assertRaises(ProvisioningError): prov.orchestrator_key() - def test_orchestrator_key_allows_empty_when_isolated(self) -> None: - # A genuinely isolated control plane (no co-located data plane) may run - # without a key — the only topology where open mode is permissible. - prov = ControlPlaneProvisioning( - topology=Topology(data_plane_shares_control_host=False)) - with patch("bot_bottle.trust_domain.host_signing_key", return_value=""): - self.assertEqual("", prov.orchestrator_key()) - def test_gateway_token_is_a_verifiable_gateway_role_token(self) -> None: prov = ControlPlaneProvisioning() with patch("bot_bottle.trust_domain.host_signing_key", return_value="k"): -- 2.52.0