feat(app): Secrets management page + backend-aware API
- new Secrets page (/secrets, nav entry): lists vault secrets, reveal/copy, add/edit/delete, shows active backend + addr + role; read-only on followers - GET /secrets/info (backend/addr/role/writable); fix /secrets CRUD to use the castle.yaml-selected backend (was defaulting to the now-empty file backend) - OpenBaoBackend.list_names drops folder entries (node-prefix groups) - tests: conftest forces file backend so host castle.yaml can't leak into tests app builds clean (tsc 0); endpoints verified live against the vault.
This commit is contained in:
@@ -7,6 +7,7 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
Clock,
|
Clock,
|
||||||
Globe,
|
Globe,
|
||||||
|
KeyRound,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Menu,
|
Menu,
|
||||||
Package,
|
Package,
|
||||||
@@ -44,6 +45,7 @@ const NAV: (NavLeaf | NavGroup)[] = [
|
|||||||
{ to: "/programs", label: "Programs", icon: Package },
|
{ to: "/programs", label: "Programs", icon: Package },
|
||||||
{ to: "/map", label: "System Map", icon: MapIcon },
|
{ to: "/map", label: "System Map", icon: MapIcon },
|
||||||
{ to: "/mesh", label: "Mesh", icon: Share2 },
|
{ to: "/mesh", label: "Mesh", icon: Share2 },
|
||||||
|
{ to: "/secrets", label: "Secrets", icon: KeyRound },
|
||||||
]
|
]
|
||||||
|
|
||||||
const COLLAPSE_KEY = "castle-nav-collapsed"
|
const COLLAPSE_KEY = "castle-nav-collapsed"
|
||||||
|
|||||||
229
app/src/pages/SecretsPage.tsx
Normal file
229
app/src/pages/SecretsPage.tsx
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
import { useState } from "react"
|
||||||
|
import {
|
||||||
|
Check,
|
||||||
|
Copy,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
KeyRound,
|
||||||
|
Loader2,
|
||||||
|
Lock,
|
||||||
|
Plus,
|
||||||
|
Save,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { PageHeader } from "@/components/PageHeader"
|
||||||
|
import { apiClient } from "@/services/api/client"
|
||||||
|
import {
|
||||||
|
useDeleteSecret,
|
||||||
|
useSecrets,
|
||||||
|
useSecretsInfo,
|
||||||
|
useSetSecret,
|
||||||
|
} from "@/services/api/hooks"
|
||||||
|
|
||||||
|
async function writeClipboard(text: string): Promise<boolean> {
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
/* fall through */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const ta = document.createElement("textarea")
|
||||||
|
ta.value = text
|
||||||
|
ta.style.position = "fixed"
|
||||||
|
ta.style.opacity = "0"
|
||||||
|
document.body.appendChild(ta)
|
||||||
|
ta.select()
|
||||||
|
const ok = document.execCommand("copy")
|
||||||
|
document.body.removeChild(ta)
|
||||||
|
return ok
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SecretsPage() {
|
||||||
|
const { data: info } = useSecretsInfo()
|
||||||
|
const { data: names, isLoading } = useSecrets()
|
||||||
|
const writable = info?.writable ?? false
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto px-6 py-8">
|
||||||
|
<PageHeader title="Secrets" subtitle="Credentials resolved for services and jobs" />
|
||||||
|
|
||||||
|
{info && (
|
||||||
|
<div className="mb-5 flex flex-wrap items-center gap-x-4 gap-y-1 rounded border border-[var(--border)] bg-black/20 px-3 py-2 text-xs">
|
||||||
|
<span className="inline-flex items-center gap-1.5 font-medium">
|
||||||
|
<KeyRound size={13} className="text-[var(--primary)]" />
|
||||||
|
backend: <span className="font-mono">{info.backend}</span>
|
||||||
|
</span>
|
||||||
|
{info.addr && <span className="font-mono text-[var(--muted)]">{info.addr}</span>}
|
||||||
|
<span className="text-[var(--muted)]">role: {info.role}</span>
|
||||||
|
{!writable && (
|
||||||
|
<span className="inline-flex items-center gap-1 text-amber-400">
|
||||||
|
<Lock size={12} /> read-only (write on the authority node)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{writable && <AddSecret />}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-[var(--muted)]">Loading…</p>
|
||||||
|
) : !names?.length ? (
|
||||||
|
<p className="text-[var(--muted)] text-sm">No secrets.</p>
|
||||||
|
) : (
|
||||||
|
<div className="mt-2 divide-y divide-[var(--border)] rounded border border-[var(--border)]">
|
||||||
|
{names.map((name) => (
|
||||||
|
<SecretRow key={name} name={name} writable={writable} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SecretRow({ name, writable }: { name: string; writable: boolean }) {
|
||||||
|
const [value, setValue] = useState<string | null>(null)
|
||||||
|
const [visible, setVisible] = useState(false)
|
||||||
|
const [dirty, setDirty] = useState(false)
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const setSecret = useSetSecret()
|
||||||
|
const delSecret = useDeleteSecret()
|
||||||
|
|
||||||
|
const reveal = async () => {
|
||||||
|
if (value === null && !loading) {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await apiClient.get<{ value: string }>(`/secrets/${name}`)
|
||||||
|
setValue(data.value)
|
||||||
|
} catch {
|
||||||
|
setValue("")
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setVisible((v) => !v)
|
||||||
|
}
|
||||||
|
|
||||||
|
const copy = async () => {
|
||||||
|
let v = value
|
||||||
|
if (v === null) {
|
||||||
|
const data = await apiClient.get<{ value: string }>(`/secrets/${name}`).catch(() => null)
|
||||||
|
v = data?.value ?? ""
|
||||||
|
setValue(v)
|
||||||
|
}
|
||||||
|
if (await writeClipboard(v)) {
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 1500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2">
|
||||||
|
<span className="w-40 sm:w-64 shrink-0 truncate font-mono text-xs" title={name}>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type={visible ? "text" : "password"}
|
||||||
|
value={value ?? "••••••••"}
|
||||||
|
readOnly={!writable || value === null}
|
||||||
|
placeholder={loading ? "loading…" : "••••••••"}
|
||||||
|
onChange={(e) => {
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<button onClick={reveal} className="p-1 shrink-0 text-[var(--muted)] hover:text-[var(--foreground)]" title="Reveal">
|
||||||
|
{visible ? <EyeOff size={13} /> : <Eye size={13} />}
|
||||||
|
</button>
|
||||||
|
<button onClick={copy} className="p-1 shrink-0 text-[var(--muted)] hover:text-[var(--foreground)]" title="Copy">
|
||||||
|
{copied ? <Check size={13} className="text-green-400" /> : <Copy size={13} />}
|
||||||
|
</button>
|
||||||
|
{writable && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (value !== null && dirty)
|
||||||
|
setSecret.mutate({ name, value }, { onSuccess: () => setDirty(false) })
|
||||||
|
}}
|
||||||
|
disabled={!dirty || setSecret.isPending}
|
||||||
|
className="p-1 shrink-0 text-blue-400 hover:text-blue-300 disabled:opacity-30"
|
||||||
|
title="Save"
|
||||||
|
>
|
||||||
|
{setSecret.isPending ? <Loader2 size={13} className="animate-spin" /> : <Save size={13} />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`Delete secret "${name}"?`)) delSecret.mutate(name)
|
||||||
|
}}
|
||||||
|
className="p-1 shrink-0 text-red-400 hover:text-red-300"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 size={13} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddSecret() {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [name, setName] = useState("")
|
||||||
|
const [value, setValue] = useState("")
|
||||||
|
const setSecret = useSetSecret()
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
if (!name.trim() || !value) return
|
||||||
|
setSecret.mutate(
|
||||||
|
{ name: name.trim(), value },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setName("")
|
||||||
|
setValue("")
|
||||||
|
setOpen(false)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
return (
|
||||||
|
<button onClick={() => setOpen(true)} className="mb-3 text-xs text-[var(--primary)] hover:underline">
|
||||||
|
<Plus size={11} className="inline mr-1" />
|
||||||
|
Add secret
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-3 flex flex-wrap items-center gap-2 rounded border border-[var(--border)] bg-black/20 p-2">
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
placeholder="NAME"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="w-40 rounded border border-[var(--border)] bg-black/30 px-2 py-1 text-xs font-mono focus:border-[var(--primary)] focus:outline-none"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
placeholder="value"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<button onClick={submit} disabled={setSecret.isPending} className="rounded bg-[var(--primary)] px-2 py-1 text-xs text-black disabled:opacity-40">
|
||||||
|
{setSecret.isPending ? "…" : "Add"}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setOpen(false)} className="px-2 py-1 text-xs text-[var(--muted)]">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { Tools } from "@/pages/Tools"
|
|||||||
import { Programs } from "@/pages/Programs"
|
import { Programs } from "@/pages/Programs"
|
||||||
import { GatewayPage } from "@/pages/GatewayPage"
|
import { GatewayPage } from "@/pages/GatewayPage"
|
||||||
import { MeshPage } from "@/pages/MeshPage"
|
import { MeshPage } from "@/pages/MeshPage"
|
||||||
|
import { SecretsPage } from "@/pages/SecretsPage"
|
||||||
import { SystemMapPage } from "@/pages/SystemMap"
|
import { SystemMapPage } from "@/pages/SystemMap"
|
||||||
import { ServiceDetailPage } from "@/pages/ServiceDetail"
|
import { ServiceDetailPage } from "@/pages/ServiceDetail"
|
||||||
import { ScheduledDetailPage } from "@/pages/ScheduledDetail"
|
import { ScheduledDetailPage } from "@/pages/ScheduledDetail"
|
||||||
@@ -27,6 +28,7 @@ export const router = createBrowserRouter([
|
|||||||
{ path: "programs", element: <Programs /> },
|
{ path: "programs", element: <Programs /> },
|
||||||
{ path: "gateway", element: <GatewayPage /> },
|
{ path: "gateway", element: <GatewayPage /> },
|
||||||
{ path: "mesh", element: <MeshPage /> },
|
{ path: "mesh", element: <MeshPage /> },
|
||||||
|
{ path: "secrets", element: <SecretsPage /> },
|
||||||
{ path: "map", element: <SystemMapPage /> },
|
{ path: "map", element: <SystemMapPage /> },
|
||||||
{ path: "services/:name", element: <ServiceDetailPage /> },
|
{ path: "services/:name", element: <ServiceDetailPage /> },
|
||||||
{ path: "jobs/:name", element: <ScheduledDetailPage /> },
|
{ path: "jobs/:name", element: <ScheduledDetailPage /> },
|
||||||
|
|||||||
@@ -543,3 +543,43 @@ export function useEventStream() {
|
|||||||
return () => es.close()
|
return () => es.close()
|
||||||
}, [qc])
|
}, [qc])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Secrets ---
|
||||||
|
|
||||||
|
export interface SecretsInfo {
|
||||||
|
backend: string
|
||||||
|
addr: string | null
|
||||||
|
role: string
|
||||||
|
writable: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSecretsInfo() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["secrets-info"],
|
||||||
|
queryFn: () => apiClient.get<SecretsInfo>("/secrets/info"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSecrets() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["secrets"],
|
||||||
|
queryFn: () => apiClient.get<string[]>("/secrets"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSetSecret() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ name, value }: { name: string; value: string }) =>
|
||||||
|
apiClient.put(`/secrets/${name}`, { value }),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["secrets"] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteSecret() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (name: string) => apiClient.delete(`/secrets/${name}`),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["secrets"] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,14 +5,17 @@ from __future__ import annotations
|
|||||||
from fastapi import APIRouter, HTTPException, status
|
from fastapi import APIRouter, HTTPException, status
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from castle_core.config import SECRETS_DIR
|
from castle_core.config import SECRETS_DIR, _secrets_settings
|
||||||
from castle_core.secret_backends import build_backend
|
from castle_core.secret_backends import OpenBaoBackend, build_backend
|
||||||
|
|
||||||
|
from castle_api.config import get_registry
|
||||||
|
|
||||||
router = APIRouter(prefix="/secrets", tags=["secrets"])
|
router = APIRouter(prefix="/secrets", tags=["secrets"])
|
||||||
|
|
||||||
|
|
||||||
def _backend():
|
def _backend():
|
||||||
return build_backend(SECRETS_DIR)
|
# Same selection as the rest of castle (castle.yaml `secrets:` block).
|
||||||
|
return build_backend(SECRETS_DIR, _secrets_settings())
|
||||||
|
|
||||||
|
|
||||||
class SecretValue(BaseModel):
|
class SecretValue(BaseModel):
|
||||||
@@ -25,6 +28,23 @@ def list_secrets() -> list[str]:
|
|||||||
return _backend().list_names()
|
return _backend().list_names()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/info")
|
||||||
|
def secrets_info() -> dict:
|
||||||
|
"""The active backend + whether this node may write (for the UI)."""
|
||||||
|
settings = _secrets_settings()
|
||||||
|
backend = _backend()
|
||||||
|
kind = "openbao" if isinstance(backend, OpenBaoBackend) else "file"
|
||||||
|
role = get_registry().node.role
|
||||||
|
# File is always writable; a vault follower holds a read-only token.
|
||||||
|
writable = kind == "file" or role == "authority"
|
||||||
|
return {
|
||||||
|
"backend": kind,
|
||||||
|
"addr": settings.get("addr") if kind == "openbao" else None,
|
||||||
|
"role": role,
|
||||||
|
"writable": writable,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{name}")
|
@router.get("/{name}")
|
||||||
def get_secret(name: str) -> dict:
|
def get_secret(name: str) -> dict:
|
||||||
"""Get a secret value."""
|
"""Get a secret value."""
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
"""Test fixtures for castle-api."""
|
"""Test fixtures for castle-api."""
|
||||||
|
|
||||||
|
import os as _os
|
||||||
|
# Tests must not read the host's real secret backend (castle.yaml may point
|
||||||
|
# at OpenBao); force the file backend unless CI explicitly overrides.
|
||||||
|
_os.environ.setdefault("CASTLE_SECRET_BACKEND", "file")
|
||||||
|
|
||||||
|
|
||||||
import socket
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
|
|||||||
@@ -107,7 +107,9 @@ class OpenBaoBackend:
|
|||||||
url = f"{self._addr}/v1/{self._mount}/metadata?list=true"
|
url = f"{self._addr}/v1/{self._mount}/metadata?list=true"
|
||||||
try:
|
try:
|
||||||
data = self._request("GET", url)
|
data = self._request("GET", url)
|
||||||
return sorted(data.get("data", {}).get("keys", []))
|
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("/"))
|
||||||
except Exception:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,14 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os as _os
|
||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Tests must not read the host's real secret backend (castle.yaml may point
|
||||||
|
# at OpenBao); force the file backend unless CI explicitly overrides.
|
||||||
|
_os.environ.setdefault("CASTLE_SECRET_BACKEND", "file")
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user