Files
wild-pc/castle-api/src/castle_api/secrets.py
Paul Payne b437f71300 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).
2026-07-07 05:44:23 -07:00

64 lines
1.6 KiB
Python

"""Secrets management — routes through the active backend (file or OpenBao)."""
from __future__ import annotations
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
@router.get("")
def list_secrets() -> list[str]:
"""List all secret names (not values)."""
return _backend().list_names()
@router.get("/{name}")
def get_secret(name: str) -> dict:
"""Get a secret value."""
_validate_name(name)
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": value}
@router.put("/{name}")
def set_secret(name: str, body: SecretValue) -> dict:
"""Set a secret value."""
_validate_name(name)
_backend().write(name, body.value)
return {"name": name, "ok": True}
@router.delete("/{name}")
def delete_secret(name: str) -> dict:
"""Delete a secret."""
_validate_name(name)
_backend().delete(name)
return {"name": name, "ok": True}
def _validate_name(name: str) -> None:
"""Reject path traversal attempts."""
if "/" in name or "\\" in name or ".." in name or not name:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid secret name",
)