feat(secrets): OpenBao-only backend via castle.yaml + per-node prefix

- backend selected by castle.yaml 'secrets:' block (env overrides); no file
  fallback in openbao mode (bootstrap token still read from file)
- route direct-read secrets (public DNS token, supabase pw) through read_secret
- OpenBaoBackend node_prefix: read <prefix>/<name> then shared <name>, so a
  shared vault serves per-node overrides (e.g. each node's postgres password)
- tests updated (fallback removed, settings + node_prefix selection)
This commit is contained in:
2026-07-07 06:34:50 -07:00
parent 813e2b01af
commit 836b1437ab
5 changed files with 90 additions and 52 deletions

View File

@@ -318,17 +318,31 @@ def resolve_env_vars(
return {k: secret[k] if k in secret else plain[k] for k in env} return {k: secret[k] if k in secret else plain[k] for k in env}
def _read_secret(name: str) -> str: def _secrets_settings() -> dict:
"""Resolve a secret via the active backend (file by default; OpenBao opt-in). """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 ``<MISSING_SECRET:...>`` 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 from castle_core.secret_backends import build_backend
value = build_backend(SECRETS_DIR).read(name) return build_backend(SECRETS_DIR, _secrets_settings()).read(name)
if value is not None:
return value
return f"<MISSING_SECRET:{name}>" def _read_secret(name: str) -> str:
"""Like :func:`read_secret` but returns a ``<MISSING_SECRET:...>`` placeholder
for unresolved secrets (used by ``${secret:...}`` env substitution)."""
value = read_secret(name)
return value if value is not None else f"<MISSING_SECRET:{name}>"
def _parse_program(name: str, data: dict) -> ProgramSpec: def _parse_program(name: str, data: dict) -> ProgramSpec:

View File

@@ -19,8 +19,6 @@ import json
import urllib.error import urllib.error
import urllib.request import urllib.request
from castle_core.config import SECRETS_DIR
_API = "https://api.cloudflare.com/client/v4" _API = "https://api.cloudflare.com/client/v4"
# Secret holding a Cloudflare token scoped to the PUBLIC zone (Zone:DNS:Edit). # 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: def public_dns_token() -> str | None:
"""The public-zone DNS token from secrets, or None if not configured.""" """The public-zone DNS token from the active secret backend, or None."""
path = SECRETS_DIR / PUBLIC_DNS_TOKEN from castle_core.config import read_secret
if path.exists():
return path.read_text().strip() or None return read_secret(PUBLIC_DNS_TOKEN) or None
return None
def _api(token: str, method: str, path: str, body: dict | None = None) -> dict: def _api(token: str, method: str, path: str, body: dict | None = None) -> dict:

View File

@@ -59,25 +59,30 @@ class FileSecretBackend:
class OpenBaoBackend: 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 ``node_prefix`` supports a shared vault with per-node overrides: a read tries
the fallback — so a partly-migrated vault and the bootstrap token both resolve. ``<node_prefix>/<name>`` first, then the shared ``<name>``. 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__( def __init__(
self, addr: str, token: str, mount: str, fallback: SecretBackend self, addr: str, token: str, mount: str, node_prefix: str | None = None
) -> None: ) -> None:
self._addr = addr.rstrip("/") self._addr = addr.rstrip("/")
self._token = token self._token = token
self._mount = mount self._mount = mount
self._fallback = fallback self._node_prefix = (node_prefix or "").strip("/")
def read(self, name: str) -> str | None: def read(self, name: str) -> str | None:
value = self._read_bao(name) if self._node_prefix:
if value is not None: override = self._read_bao(f"{self._node_prefix}/{name}")
return value if override is not None:
return self._fallback.read(name) return override
return self._read_bao(name)
def _read_bao(self, name: str) -> str | None: def _read_bao(self, name: str) -> str | None:
if not self._token: if not self._token:
@@ -122,16 +127,33 @@ class OpenBaoBackend:
return json.loads(raw) if raw else {} return json.loads(raw) if raw else {}
def build_backend(secrets_dir: Path) -> SecretBackend: def build_backend(secrets_dir: Path, settings: dict | None = None) -> SecretBackend:
"""Construct the active secret backend from the environment.""" """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) 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": if kind == "openbao":
addr = os.environ.get("CASTLE_OPENBAO_ADDR", "http://localhost:8200") addr = os.environ.get("CASTLE_OPENBAO_ADDR") or settings.get(
mount = os.environ.get("CASTLE_OPENBAO_MOUNT", "castle") "addr"
token_secret = os.environ.get("CASTLE_OPENBAO_TOKEN_SECRET", "OPENBAO_TOKEN") ) 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( token = file_backend.read(token_secret) or os.environ.get(
"CASTLE_OPENBAO_TOKEN", "" "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 return file_backend

View File

@@ -410,11 +410,10 @@ def _substrate_db_url() -> str | None:
explicit = os.environ.get("SUPABASE_DB_URL") explicit = os.environ.get("SUPABASE_DB_URL")
if explicit: if explicit:
return explicit return explicit
from castle_core.config import SECRETS_DIR from castle_core.config import read_secret
pw_file = SECRETS_DIR / "SUPABASE_POSTGRES_PASSWORD" pw = read_secret("SUPABASE_POSTGRES_PASSWORD")
if pw_file.exists(): if pw:
pw = pw_file.read_text().strip()
port = os.environ.get("SUPABASE_DB_HOST_PORT", "5433") port = os.environ.get("SUPABASE_DB_HOST_PORT", "5433")
return f"postgresql://postgres:{pw}@localhost:{port}/postgres" return f"postgresql://postgres:{pw}@localhost:{port}/postgres"
return None return None

View File

@@ -38,30 +38,36 @@ def test_build_backend_defaults_to_file(tmp_path: Path, monkeypatch) -> None:
assert isinstance(build_backend(tmp_path), FileSecretBackend) 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") monkeypatch.setenv("CASTLE_SECRET_BACKEND", "openbao")
assert isinstance(build_backend(tmp_path), OpenBaoBackend) assert isinstance(build_backend(tmp_path), OpenBaoBackend)
def test_openbao_falls_back_to_file_when_unreachable(tmp_path: Path) -> None: def test_build_backend_openbao_via_settings(tmp_path: Path, monkeypatch) -> None:
"""An unreachable vault (or empty token) resolves via the file fallback.""" """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") (tmp_path / "ONLY_IN_FILE").write_text("from-file")
backend = OpenBaoBackend( backend = OpenBaoBackend(addr="http://127.0.0.1:1", token="dummy", mount="castle")
addr="http://127.0.0.1:1", # nothing listening assert backend.read("ONLY_IN_FILE") is None
token="dummy",
mount="castle",
fallback=FileSecretBackend(tmp_path),
)
assert backend.read("ONLY_IN_FILE") == "from-file"
assert backend.read("NOT_ANYWHERE") is None assert backend.read("NOT_ANYWHERE") is None
def test_openbao_empty_token_uses_fallback(tmp_path: Path) -> None: def test_openbao_empty_token_returns_none(tmp_path: Path) -> None:
(tmp_path / "K").write_text("v") backend = OpenBaoBackend(addr="http://127.0.0.1:8200", token="", mount="castle")
backend = OpenBaoBackend( assert backend.read("K") is None
addr="http://127.0.0.1:8200",
token="", # no token → never hits the network
mount="castle", def test_openbao_node_prefix_from_settings(tmp_path: Path, monkeypatch) -> None:
fallback=FileSecretBackend(tmp_path), 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"