feat(app): node-override secrets UX + supabase scaffold refresh
- Secrets page: 'Node overrides' section (per-node values that shadow a shared
secret on that node) — list/reveal/edit/delete + add; shared secrets show an
'override: <node>' badge. Authority-only writes.
- API: GET /secrets/overrides, GET/PUT/DELETE /secrets/overrides/{node}/{name};
OpenBaoBackend.list_node_overrides enumerates castle/nodes/*
- supabase scaffold: drop stale 'cat ~/.castle/secrets/...' + 'castle deploy',
point at the dashboard Secrets page / castle apply
- tests: file-backend override endpoints (empty/400/404)
Verified live: primer's postgres override discoverable + full CRUD round-trip.
This commit is contained in:
@@ -45,6 +45,48 @@ def secrets_info() -> dict:
|
||||
}
|
||||
|
||||
|
||||
@router.get("/overrides")
|
||||
def list_overrides() -> dict:
|
||||
"""Per-node secret overrides ({host: [names]}). Empty unless OpenBao."""
|
||||
backend = _backend()
|
||||
if not isinstance(backend, OpenBaoBackend):
|
||||
return {"overrides": {}}
|
||||
return {"overrides": backend.list_node_overrides()}
|
||||
|
||||
|
||||
@router.get("/overrides/{node}/{name:path}")
|
||||
def get_override(node: str, name: str) -> dict:
|
||||
"""Read a node's override value."""
|
||||
value = _backend().read(f"nodes/{node}/{name}")
|
||||
if value is None:
|
||||
raise HTTPException(
|
||||
status.HTTP_404_NOT_FOUND, f"no override '{name}' for node '{node}'"
|
||||
)
|
||||
return {"node": node, "name": name, "value": value}
|
||||
|
||||
|
||||
@router.put("/overrides/{node}/{name:path}")
|
||||
def set_override(node: str, name: str, body: SecretValue) -> dict:
|
||||
"""Set a per-node override (authority only; needs the OpenBao backend)."""
|
||||
backend = _backend()
|
||||
if not isinstance(backend, OpenBaoBackend):
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST, "node overrides require the OpenBao backend"
|
||||
)
|
||||
try:
|
||||
backend.write(f"nodes/{node}/{name}", body.value)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, str(exc)) from exc
|
||||
return {"node": node, "name": name, "ok": True}
|
||||
|
||||
|
||||
@router.delete("/overrides/{node}/{name:path}")
|
||||
def delete_override(node: str, name: str) -> dict:
|
||||
"""Remove a per-node override."""
|
||||
_backend().delete(f"nodes/{node}/{name}")
|
||||
return {"node": node, "name": name, "ok": True}
|
||||
|
||||
|
||||
@router.get("/{name}")
|
||||
def get_secret(name: str) -> dict:
|
||||
"""Get a secret value."""
|
||||
|
||||
24
castle-api/tests/test_secrets_overrides.py
Normal file
24
castle-api/tests/test_secrets_overrides.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""HTTP-layer tests for the node-override endpoints (file-backend paths).
|
||||
|
||||
The live OpenBao override round-trip is verified manually; here we pin the
|
||||
file-backend behavior (overrides are an OpenBao-only feature).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def test_list_overrides_empty_on_file_backend(client: TestClient) -> None:
|
||||
r = client.get("/secrets/overrides")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"overrides": {}}
|
||||
|
||||
|
||||
def test_set_override_rejected_on_file_backend(client: TestClient) -> None:
|
||||
r = client.put("/secrets/overrides/primer/POSTGRES_PASSWORD", json={"value": "x"})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_get_missing_override_is_404(client: TestClient) -> None:
|
||||
assert client.get("/secrets/overrides/primer/NOPE").status_code == 404
|
||||
Reference in New Issue
Block a user