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:
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user