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}
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 ``<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
value = build_backend(SECRETS_DIR).read(name)
if value is not None:
return value
return f"<MISSING_SECRET:{name}>"
return build_backend(SECRETS_DIR, _secrets_settings()).read(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:

View File

@@ -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:

View File

@@ -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
``<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__(
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

View File

@@ -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

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