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