- 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).
64 lines
1.6 KiB
Python
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",
|
|
)
|