diff --git a/core/src/castle_core/config.py b/core/src/castle_core/config.py index f7fe9a6..6dae35c 100644 --- a/core/src/castle_core/config.py +++ b/core/src/castle_core/config.py @@ -318,17 +318,31 @@ def resolve_env_vars( return {k: secret[k] if k in secret else plain[k] for k in env} -def _read_secret(name: str) -> str: - """Resolve a secret via the active backend (file by default; OpenBao opt-in). +def _secrets_settings() -> dict: + """The ``secrets:`` block of castle.yaml — selects the backend.""" + try: + data = yaml.safe_load((CASTLE_HOME / "castle.yaml").read_text()) or {} + return data.get("secrets") or {} + except Exception: + return {} - Returns a ```` placeholder if unresolved (never raises). + +def read_secret(name: str) -> str | None: + """Resolve a secret via the active backend, or None if unset. + + The public helper for code that reads secrets directly (DNS/supabase/etc.) so + everything goes through the one backend rather than the filesystem. """ from castle_core.secret_backends import build_backend - value = build_backend(SECRETS_DIR).read(name) - if value is not None: - return value - return f"" + return build_backend(SECRETS_DIR, _secrets_settings()).read(name) + + +def _read_secret(name: str) -> str: + """Like :func:`read_secret` but returns a ```` placeholder + for unresolved secrets (used by ``${secret:...}`` env substitution).""" + value = read_secret(name) + return value if value is not None else f"" def _parse_program(name: str, data: dict) -> ProgramSpec: diff --git a/core/src/castle_core/generators/dns.py b/core/src/castle_core/generators/dns.py index 18724ce..43e9eaf 100644 --- a/core/src/castle_core/generators/dns.py +++ b/core/src/castle_core/generators/dns.py @@ -19,8 +19,6 @@ import json import urllib.error import urllib.request -from castle_core.config import SECRETS_DIR - _API = "https://api.cloudflare.com/client/v4" # Secret holding a Cloudflare token scoped to the PUBLIC zone (Zone:DNS:Edit). @@ -30,11 +28,10 @@ PUBLIC_DNS_TOKEN = "CLOUDFLARE_PUBLIC_DNS_TOKEN" def public_dns_token() -> str | None: - """The public-zone DNS token from secrets, or None if not configured.""" - path = SECRETS_DIR / PUBLIC_DNS_TOKEN - if path.exists(): - return path.read_text().strip() or None - return None + """The public-zone DNS token from the active secret backend, or None.""" + from castle_core.config import read_secret + + return read_secret(PUBLIC_DNS_TOKEN) or None def _api(token: str, method: str, path: str, body: dict | None = None) -> dict: diff --git a/core/src/castle_core/secret_backends.py b/core/src/castle_core/secret_backends.py index 14e18f1..380cfd2 100644 --- a/core/src/castle_core/secret_backends.py +++ b/core/src/castle_core/secret_backends.py @@ -59,25 +59,30 @@ class FileSecretBackend: class OpenBaoBackend: - """Reads from an OpenBao/Vault KV-v2 mount; falls back to ``fallback``. + """Reads/writes an OpenBao/Vault KV-v2 mount. No file fallback — a missing key + or unreachable server returns None (the bootstrap token is read separately by + ``build_backend`` via the file backend, not through here). - A missing key, an auth failure, or an unreachable server all fall through to - the fallback — so a partly-migrated vault and the bootstrap token both resolve. + ``node_prefix`` supports a shared vault with per-node overrides: a read tries + ``/`` first, then the shared ````. So a node-specific + secret (e.g. that node's postgres password) lives at the prefixed path while + shared secrets (a common token) live at the base — no name collision. """ def __init__( - self, addr: str, token: str, mount: str, fallback: SecretBackend + self, addr: str, token: str, mount: str, node_prefix: str | None = None ) -> None: self._addr = addr.rstrip("/") self._token = token self._mount = mount - self._fallback = fallback + self._node_prefix = (node_prefix or "").strip("/") def read(self, name: str) -> str | None: - value = self._read_bao(name) - if value is not None: - return value - return self._fallback.read(name) + if self._node_prefix: + override = self._read_bao(f"{self._node_prefix}/{name}") + if override is not None: + return override + return self._read_bao(name) def _read_bao(self, name: str) -> str | None: if not self._token: @@ -122,16 +127,33 @@ class OpenBaoBackend: return json.loads(raw) if raw else {} -def build_backend(secrets_dir: Path) -> SecretBackend: - """Construct the active secret backend from the environment.""" +def build_backend(secrets_dir: Path, settings: dict | None = None) -> SecretBackend: + """Construct the active secret backend. + + Selection comes from ``settings`` (the ``secrets:`` block of castle.yaml), with + environment variables overriding — so production is configured declaratively in + castle.yaml while tests/CI can force a backend via env. Default: file. + + The OpenBao **token** is still read from the file backend (the bootstrap root of + trust — it can't live in the vault it unlocks); everything else comes from the + vault with no file fallback. + """ + settings = settings or {} file_backend = FileSecretBackend(secrets_dir) - kind = os.environ.get("CASTLE_SECRET_BACKEND", "file").lower() + kind = (os.environ.get("CASTLE_SECRET_BACKEND") or settings.get("backend") or "file").lower() if kind == "openbao": - addr = os.environ.get("CASTLE_OPENBAO_ADDR", "http://localhost:8200") - mount = os.environ.get("CASTLE_OPENBAO_MOUNT", "castle") - token_secret = os.environ.get("CASTLE_OPENBAO_TOKEN_SECRET", "OPENBAO_TOKEN") + addr = os.environ.get("CASTLE_OPENBAO_ADDR") or settings.get( + "addr" + ) or "http://localhost:8200" + mount = os.environ.get("CASTLE_OPENBAO_MOUNT") or settings.get("mount") or "castle" + token_secret = os.environ.get("CASTLE_OPENBAO_TOKEN_SECRET") or settings.get( + "token_secret" + ) or "OPENBAO_TOKEN" token = file_backend.read(token_secret) or os.environ.get( "CASTLE_OPENBAO_TOKEN", "" ) - return OpenBaoBackend(addr, token, mount, fallback=file_backend) + node_prefix = os.environ.get("CASTLE_OPENBAO_NODE_PREFIX") or settings.get( + "node_prefix" + ) + return OpenBaoBackend(addr, token, mount, node_prefix) return file_backend diff --git a/core/src/castle_core/stacks.py b/core/src/castle_core/stacks.py index 574eb64..0185d09 100644 --- a/core/src/castle_core/stacks.py +++ b/core/src/castle_core/stacks.py @@ -410,11 +410,10 @@ def _substrate_db_url() -> str | None: explicit = os.environ.get("SUPABASE_DB_URL") if explicit: return explicit - from castle_core.config import SECRETS_DIR + from castle_core.config import read_secret - pw_file = SECRETS_DIR / "SUPABASE_POSTGRES_PASSWORD" - if pw_file.exists(): - pw = pw_file.read_text().strip() + pw = read_secret("SUPABASE_POSTGRES_PASSWORD") + if pw: port = os.environ.get("SUPABASE_DB_HOST_PORT", "5433") return f"postgresql://postgres:{pw}@localhost:{port}/postgres" return None diff --git a/core/tests/test_secret_backends.py b/core/tests/test_secret_backends.py index c9b945b..8511a1c 100644 --- a/core/tests/test_secret_backends.py +++ b/core/tests/test_secret_backends.py @@ -38,30 +38,36 @@ def test_build_backend_defaults_to_file(tmp_path: Path, monkeypatch) -> None: assert isinstance(build_backend(tmp_path), FileSecretBackend) -def test_build_backend_openbao_selected(tmp_path: Path, monkeypatch) -> None: +def test_build_backend_openbao_via_env(tmp_path: Path, monkeypatch) -> None: monkeypatch.setenv("CASTLE_SECRET_BACKEND", "openbao") assert isinstance(build_backend(tmp_path), OpenBaoBackend) -def test_openbao_falls_back_to_file_when_unreachable(tmp_path: Path) -> None: - """An unreachable vault (or empty token) resolves via the file fallback.""" +def test_build_backend_openbao_via_settings(tmp_path: Path, monkeypatch) -> None: + """The castle.yaml `secrets:` block selects the backend (env still overrides).""" + monkeypatch.delenv("CASTLE_SECRET_BACKEND", raising=False) + settings = {"backend": "openbao", "addr": "https://vault:8200", "mount": "castle"} + assert isinstance(build_backend(tmp_path, settings), OpenBaoBackend) + + +def test_openbao_unreachable_returns_none_no_fallback(tmp_path: Path) -> None: + """No file fallback: an unreachable vault returns None even if a file exists.""" (tmp_path / "ONLY_IN_FILE").write_text("from-file") - backend = OpenBaoBackend( - addr="http://127.0.0.1:1", # nothing listening - token="dummy", - mount="castle", - fallback=FileSecretBackend(tmp_path), - ) - assert backend.read("ONLY_IN_FILE") == "from-file" + backend = OpenBaoBackend(addr="http://127.0.0.1:1", token="dummy", mount="castle") + assert backend.read("ONLY_IN_FILE") is None assert backend.read("NOT_ANYWHERE") is None -def test_openbao_empty_token_uses_fallback(tmp_path: Path) -> None: - (tmp_path / "K").write_text("v") - backend = OpenBaoBackend( - addr="http://127.0.0.1:8200", - token="", # no token → never hits the network - mount="castle", - fallback=FileSecretBackend(tmp_path), +def test_openbao_empty_token_returns_none(tmp_path: Path) -> None: + backend = OpenBaoBackend(addr="http://127.0.0.1:8200", token="", mount="castle") + assert backend.read("K") is None + + +def test_openbao_node_prefix_from_settings(tmp_path: Path, monkeypatch) -> None: + monkeypatch.delenv("CASTLE_SECRET_BACKEND", raising=False) + backend = build_backend( + tmp_path, + {"backend": "openbao", "addr": "http://x", "node_prefix": "nodes/primer"}, ) - assert backend.read("K") == "v" + assert isinstance(backend, OpenBaoBackend) + assert backend._node_prefix == "nodes/primer"