diff --git a/app/src/pages/SecretsPage.tsx b/app/src/pages/SecretsPage.tsx index 6610e22..e21f093 100644 --- a/app/src/pages/SecretsPage.tsx +++ b/app/src/pages/SecretsPage.tsx @@ -9,14 +9,18 @@ import { Lock, Plus, Save, + Server, Trash2, } from "lucide-react" import { PageHeader } from "@/components/PageHeader" import { apiClient } from "@/services/api/client" import { + useDeleteOverride, useDeleteSecret, + useSecretOverrides, useSecrets, useSecretsInfo, + useSetOverride, useSetSecret, } from "@/services/api/hooks" @@ -47,7 +51,15 @@ async function writeClipboard(text: string): Promise { export function SecretsPage() { const { data: info } = useSecretsInfo() const { data: names, isLoading } = useSecrets() + const { data: overridesResp } = useSecretOverrides() const writable = info?.writable ?? false + const overrides = overridesResp?.overrides ?? {} + + // name -> [nodes that override it], for the per-secret badge. + const overriddenBy: Record = {} + for (const [node, secretNames] of Object.entries(overrides)) { + for (const n of secretNames) (overriddenBy[n] ??= []).push(node) + } return (
@@ -78,15 +90,30 @@ export function SecretsPage() { ) : (
{names.map((name) => ( - + ))}
)} + +
) } -function SecretRow({ name, writable }: { name: string; writable: boolean }) { +function SecretRow({ + name, + writable, + overriddenOn = [], +}: { + name: string + writable: boolean + overriddenOn?: string[] +}) { const [value, setValue] = useState(null) const [visible, setVisible] = useState(false) const [dirty, setDirty] = useState(false) @@ -127,6 +154,14 @@ function SecretRow({ name, writable }: { name: string; writable: boolean }) {
{name} + {overriddenOn.length > 0 && ( + + override: {overriddenOn.join(", ")} + + )} ) } + +function NodeOverrides({ + overrides, + writable, + secretNames, +}: { + overrides: Record + writable: boolean + secretNames: string[] +}) { + const entries = Object.entries(overrides).flatMap(([node, names]) => + names.map((name) => ({ node, name })), + ) + + return ( +
+
+ +

Node overrides

+ + per-node values (e.g. a node's own postgres password); shadow the shared secret on that node + +
+ + {writable && } + + {entries.length === 0 ? ( +

No overrides.

+ ) : ( +
+ {entries.map(({ node, name }) => ( + + ))} +
+ )} +
+ ) +} + +function OverrideRow({ node, name, writable }: { node: string; name: string; writable: boolean }) { + const [value, setValue] = useState(null) + const [visible, setVisible] = useState(false) + const [dirty, setDirty] = useState(false) + const setOverride = useSetOverride() + const delOverride = useDeleteOverride() + + const reveal = async () => { + if (value === null) { + const data = await apiClient + .get<{ value: string }>(`/secrets/overrides/${node}/${name}`) + .catch(() => null) + setValue(data?.value ?? "") + } + setVisible((v) => !v) + } + + return ( +
+ + {node} + + + {name} + + { + setValue(e.target.value) + setDirty(true) + }} + className="flex-1 min-w-0 rounded border border-[var(--border)] bg-black/30 px-2 py-1 text-xs font-mono focus:border-[var(--primary)] focus:outline-none" + /> + + {writable && ( + <> + + + + )} +
+ ) +} + +function AddOverride({ secretNames }: { secretNames: string[] }) { + const [open, setOpen] = useState(false) + const [node, setNode] = useState("") + const [name, setName] = useState("") + const [value, setValue] = useState("") + const setOverride = useSetOverride() + + const submit = () => { + if (!node.trim() || !name.trim() || !value) return + setOverride.mutate( + { node: node.trim(), name: name.trim(), value }, + { + onSuccess: () => { + setNode("") + setName("") + setValue("") + setOpen(false) + }, + }, + ) + } + + if (!open) { + return ( + + ) + } + + return ( +
+ setNode(e.target.value)} + className="w-32 rounded border border-[var(--border)] bg-black/30 px-2 py-1 text-xs font-mono focus:border-[var(--primary)] focus:outline-none" + /> + setName(e.target.value)} + className="w-44 rounded border border-[var(--border)] bg-black/30 px-2 py-1 text-xs font-mono focus:border-[var(--primary)] focus:outline-none" + /> + + {secretNames.map((n) => ( + + setValue(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && submit()} + className="flex-1 min-w-0 rounded border border-[var(--border)] bg-black/30 px-2 py-1 text-xs font-mono focus:border-[var(--primary)] focus:outline-none" + /> + + +
+ ) +} diff --git a/app/src/services/api/hooks.ts b/app/src/services/api/hooks.ts index 8381528..b5db830 100644 --- a/app/src/services/api/hooks.ts +++ b/app/src/services/api/hooks.ts @@ -583,3 +583,29 @@ export function useDeleteSecret() { onSuccess: () => qc.invalidateQueries({ queryKey: ["secrets"] }), }) } + +export function useSecretOverrides() { + return useQuery({ + queryKey: ["secret-overrides"], + queryFn: () => + apiClient.get<{ overrides: Record }>("/secrets/overrides"), + }) +} + +export function useSetOverride() { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ node, name, value }: { node: string; name: string; value: string }) => + apiClient.put(`/secrets/overrides/${node}/${name}`, { value }), + onSuccess: () => qc.invalidateQueries({ queryKey: ["secret-overrides"] }), + }) +} + +export function useDeleteOverride() { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ node, name }: { node: string; name: string }) => + apiClient.delete(`/secrets/overrides/${node}/${name}`), + onSuccess: () => qc.invalidateQueries({ queryKey: ["secret-overrides"] }), + }) +} diff --git a/castle-api/src/castle_api/secrets.py b/castle-api/src/castle_api/secrets.py index dc425e5..5d42b31 100644 --- a/castle-api/src/castle_api/secrets.py +++ b/castle-api/src/castle_api/secrets.py @@ -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.""" diff --git a/castle-api/tests/test_secrets_overrides.py b/castle-api/tests/test_secrets_overrides.py new file mode 100644 index 0000000..83998bf --- /dev/null +++ b/castle-api/tests/test_secrets_overrides.py @@ -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 diff --git a/cli/src/castle_cli/templates/scaffold.py b/cli/src/castle_cli/templates/scaffold.py index ef4ef44..2b3895f 100644 --- a/cli/src/castle_cli/templates/scaffold.py +++ b/cli/src/castle_cli/templates/scaffold.py @@ -675,8 +675,8 @@ serve((_req: Request) => { project_dir / "public" / "config.js", sub( """// Substrate wiring for __NAME__. The anon key is designed to be public -// (RLS enforces access) — paste yours here, or inject at deploy time: -// cat ~/.castle/secrets/SUPABASE_ANON_KEY +// (RLS enforces access) — paste yours here. Find it in the dashboard +// Secrets page (or `castle mesh`/vault), secret name SUPABASE_ANON_KEY. window.APP = { SUPABASE_URL: "https://supabase.lan", SUPABASE_ANON_KEY: "PASTE_ANON_KEY_HERE", @@ -758,8 +758,9 @@ live on the substrate; rebuild the rest from git anytime. ## Develop - Edit `migrations/`, then `castle program build __NAME__` to apply new migrations (re-running is a no-op — only unapplied migrations run). -- Set the anon key in `public/config.js` (`cat ~/.castle/secrets/SUPABASE_ANON_KEY`). -- `castle deploy && castle gateway reload` → served at `/__NAME__/`. +- Set the anon key in `public/config.js` (find `SUPABASE_ANON_KEY` in the + dashboard Secrets page). +- `castle apply` → served at `__NAME__.`. ## Privacy note RLS protects rows, not the static shell or Storage. For a `private`/`shared` app, diff --git a/core/src/castle_core/secret_backends.py b/core/src/castle_core/secret_backends.py index 6a245d4..7a7a7df 100644 --- a/core/src/castle_core/secret_backends.py +++ b/core/src/castle_core/secret_backends.py @@ -104,15 +104,29 @@ class OpenBaoBackend: self._request("DELETE", url) def list_names(self) -> list[str]: - url = f"{self._addr}/v1/{self._mount}/metadata?list=true" + # Drop folder entries (e.g. "nodes/") — those group per-node overrides. + return sorted(k for k in self._list_path("") if not k.endswith("/")) + + def _list_path(self, prefix: str) -> list[str]: + sep = "/" if prefix else "" + url = f"{self._addr}/v1/{self._mount}/metadata{sep}{prefix}?list=true" try: - data = self._request("GET", url) - keys = data.get("data", {}).get("keys", []) - # Drop folder entries (e.g. "nodes/") — those group per-node overrides. - return sorted(k for k in keys if not k.endswith("/")) + return self._request("GET", url).get("data", {}).get("keys", []) except Exception: return [] + def list_node_overrides(self) -> dict[str, list[str]]: + """Per-node secret overrides: ``{host: [names]}`` from ``/nodes/*``.""" + out: dict[str, list[str]] = {} + for entry in self._list_path("nodes"): + if not entry.endswith("/"): + continue + host = entry.rstrip("/") + names = [k for k in self._list_path(f"nodes/{host}") if not k.endswith("/")] + if names: + out[host] = sorted(names) + return out + def _request(self, method: str, url: str, body: dict | None = None) -> dict: payload = json.dumps(body).encode() if body is not None else None req = urllib.request.Request( # noqa: S310