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"):