feat(secrets): OpenBao write path + auto-unseal on boot (Phase 4 hardening)

- SecretBackend gains write/delete/list_names; FileSecretBackend + OpenBaoBackend
  implement them (vault KV-v2 POST/DELETE/LIST); castle-api/secrets.py routes all
  CRUD through the active backend (dashboard writes to the vault in openbao mode)
- add systemd exec_start_post (general; '-' prefix so a hook failure can't fail
  the unit); castle-openbao runs an unseal.sh on boot using OPENBAO_UNSEAL_KEY
- tests: file write/read/list/delete round-trip

Verified live: write→vault→read→list→delete against OpenBao; seal→restart→
auto-unsealed. TLS hardening remains (deferred; gates cross-network/real secrets).
This commit is contained in:
2026-07-07 05:44:23 -07:00
parent 526736f778
commit b437f71300
6 changed files with 103 additions and 27 deletions

View File

@@ -1,4 +1,4 @@
"""Secrets management — read and write ~/.castle/secrets/."""
"""Secrets management — routes through the active backend (file or OpenBao)."""
from __future__ import annotations
@@ -6,10 +6,15 @@ from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel
from castle_core.config import SECRETS_DIR
from castle_core.secret_backends import build_backend
router = APIRouter(prefix="/secrets", tags=["secrets"])
def _backend():
return build_backend(SECRETS_DIR)
class SecretValue(BaseModel):
value: str
@@ -17,31 +22,27 @@ class SecretValue(BaseModel):
@router.get("")
def list_secrets() -> list[str]:
"""List all secret names (not values)."""
if not SECRETS_DIR.exists():
return []
return sorted(f.name for f in SECRETS_DIR.iterdir() if f.is_file())
return _backend().list_names()
@router.get("/{name}")
def get_secret(name: str) -> dict:
"""Get a secret value."""
_validate_name(name)
path = SECRETS_DIR / name
if not path.exists():
value = _backend().read(name)
if value is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Secret '{name}' not found",
)
return {"name": name, "value": path.read_text().strip()}
return {"name": name, "value": value}
@router.put("/{name}")
def set_secret(name: str, body: SecretValue) -> dict:
"""Set a secret value."""
_validate_name(name)
SECRETS_DIR.mkdir(parents=True, exist_ok=True)
path = SECRETS_DIR / name
path.write_text(body.value.strip() + "\n")
_backend().write(name, body.value)
return {"name": name, "ok": True}
@@ -49,9 +50,7 @@ def set_secret(name: str, body: SecretValue) -> dict:
def delete_secret(name: str) -> dict:
"""Delete a secret."""
_validate_name(name)
path = SECRETS_DIR / name
if path.exists():
path.unlink()
_backend().delete(name)
return {"name": name, "ok": True}