feat(peers): full trust-store UI — accept, reject, revoke, re-authorize
- /api/peers/reject forwards to the auth service's new /internal/reject (pending request denied + audited; peer stays on the no-op path). - TrustedServices panel now has three sections: Requested (Accept/Reject), Accepted (Revoke), and Revoked (Re-authorize), with timestamps. - The old pending 'Ignore' button mapped to revoke, which was the wrong operation for a never-approved request.
This commit is contained in:
@@ -546,3 +546,26 @@ paths:
|
|||||||
description: Missing service or fingerprint
|
description: Missing service or fingerprint
|
||||||
'502':
|
'502':
|
||||||
description: Failed to reach the shared auth service
|
description: Failed to reach the shared auth service
|
||||||
|
|
||||||
|
/api/peers/reject:
|
||||||
|
post:
|
||||||
|
summary: Reject a pending peer authorization request
|
||||||
|
tags: ["security"]
|
||||||
|
description: >
|
||||||
|
Denies a first-contact authorization request without authorizing it.
|
||||||
|
The auth service drops the request from the pending list and audits
|
||||||
|
the decision; the peer stays on the no-op path (a later contact simply
|
||||||
|
re-records a fresh pending request).
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/PeerAction'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Pending request rejected
|
||||||
|
'400':
|
||||||
|
description: Missing service or fingerprint
|
||||||
|
'502':
|
||||||
|
description: Failed to reach the shared auth service
|
||||||
|
|||||||
@@ -16,11 +16,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from 'preact/hooks'
|
import { useEffect, useState } from 'preact/hooks'
|
||||||
import { BadgeCheck, Check, LoaderCircle, ShieldCheck, X } from 'lucide-react'
|
import { BadgeCheck, Check, LoaderCircle, RotateCcw, ShieldCheck, X } from 'lucide-react'
|
||||||
import { Button } from '@components/ui/button'
|
import { Button } from '@components/ui/button'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@components/ui/card'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { approvePeer, fetchTrustState, revokePeer } from '@lib/api/peers'
|
import { approvePeer, fetchTrustState, rejectPeer, revokePeer } from '@lib/api/peers'
|
||||||
import { useAsyncAction } from '@lib/hooks/useAsyncAction'
|
import { useAsyncAction } from '@lib/hooks/useAsyncAction'
|
||||||
import { cn } from '@lib/utils'
|
import { cn } from '@lib/utils'
|
||||||
import type { PeerAuthorized, PeerPending, PeerTrustState } from '@lib/types'
|
import type { PeerAuthorized, PeerPending, PeerTrustState } from '@lib/types'
|
||||||
@@ -39,11 +39,19 @@ function shortFingerprint(fp: string): string {
|
|||||||
return `${fp.slice(0, 16)}…`
|
return `${fp.slice(0, 16)}…`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fmtTime(iso?: string | null): string {
|
||||||
|
if (!iso) return ''
|
||||||
|
const d = new Date(iso)
|
||||||
|
if (Number.isNaN(d.getTime())) return iso
|
||||||
|
return d.toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The trusted-services surface (security theme M2 §3): pending first-contact
|
* The trusted-services surface (security theme M2 §3): pending first-contact
|
||||||
* authorizations and the approved trust store. Maestro is only the UI — the
|
* authorizations, the approved trust store, and revoked keys. Maestro is only
|
||||||
* auth service owns the enforcement, so this panel is a convenience, not a
|
* the UI — the auth service owns the enforcement, so this panel is a
|
||||||
* security boundary. Hidden entirely when the management API is unreachable.
|
* convenience, not a security boundary. Hidden entirely when the management
|
||||||
|
* API is unreachable.
|
||||||
*/
|
*/
|
||||||
export function TrustedServices() {
|
export function TrustedServices() {
|
||||||
const [state, setState] = useState<LoadState>({ kind: 'loading' })
|
const [state, setState] = useState<LoadState>({ kind: 'loading' })
|
||||||
@@ -63,10 +71,13 @@ export function TrustedServices() {
|
|||||||
|
|
||||||
if (state.kind === 'loading') return null
|
if (state.kind === 'loading') return null
|
||||||
if (state.kind === 'error') return null
|
if (state.kind === 'error') return null
|
||||||
if (state.state.pending.length === 0 && state.state.authorized.length === 0) return null
|
|
||||||
|
|
||||||
const pending = state.state.pending
|
const pending = state.state.pending ?? []
|
||||||
const authorized = state.state.authorized.filter((k) => !k.revoked_at)
|
const authorized = state.state.authorized ?? []
|
||||||
|
const active = authorized.filter((k) => !k.revoked_at)
|
||||||
|
const revoked = authorized.filter((k) => k.revoked_at)
|
||||||
|
|
||||||
|
if (pending.length === 0 && active.length === 0 && revoked.length === 0) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card data-slot="trusted-services">
|
<Card data-slot="trusted-services">
|
||||||
@@ -76,26 +87,44 @@ export function TrustedServices() {
|
|||||||
Trusted services
|
Trusted services
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Services asking to talk to the auth service. Approve a fingerprint to authorize it (SSH
|
Services asking to talk to the auth service. Accept authorizes a fingerprint (SSH
|
||||||
authorized_keys, applied to the service fleet); revoke cuts it off immediately.
|
authorized_keys, applied to the service fleet), reject drops a request without
|
||||||
|
authorizing it, revoke cuts off an accepted peer immediately, re-authorize restores one.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
{pending.length > 0 && (
|
{pending.length > 0 && (
|
||||||
<CardContent data-slot="trusted-pending" className="grid gap-2">
|
<CardContent data-slot="trusted-pending" className="grid gap-2">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wide text-amber-600">
|
||||||
|
Requested access ({pending.length})
|
||||||
|
</p>
|
||||||
{pending.map((p) => (
|
{pending.map((p) => (
|
||||||
<PendingRow key={`${p.service}:${p.fingerprint}`} entry={p} onDone={load} />
|
<PendingRow key={`${p.service}:${p.fingerprint}`} entry={p} onDone={load} />
|
||||||
))}
|
))}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{authorized.length > 0 && (
|
{active.length > 0 && (
|
||||||
<CardContent data-slot="trusted-approved" className="grid gap-2">
|
<CardContent data-slot="trusted-approved" className="grid gap-2">
|
||||||
{authorized.map((k) => (
|
<p className="text-xs font-medium uppercase tracking-wide text-emerald-600">
|
||||||
|
Accepted ({active.length})
|
||||||
|
</p>
|
||||||
|
{active.map((k) => (
|
||||||
<AuthorizedRow key={`${k.service}:${k.fingerprint}`} entry={k} onDone={load} />
|
<AuthorizedRow key={`${k.service}:${k.fingerprint}`} entry={k} onDone={load} />
|
||||||
))}
|
))}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{revoked.length > 0 && (
|
||||||
|
<CardContent data-slot="trusted-revoked" className="grid gap-2">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Revoked ({revoked.length})
|
||||||
|
</p>
|
||||||
|
{revoked.map((k) => (
|
||||||
|
<RevokedRow key={`${k.service}:${k.fingerprint}`} entry={k} onDone={load} />
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -111,13 +140,13 @@ function PendingRow({ entry, onDone }: { entry: PeerPending; onDone: () => void
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const deny = useAsyncAction(async () => {
|
const reject = useAsyncAction(async () => {
|
||||||
try {
|
try {
|
||||||
await revokePeer(entry.service, entry.fingerprint)
|
await rejectPeer(entry.service, entry.fingerprint)
|
||||||
toast.success(`Ignored ${entry.service}`, { description: shortFingerprint(entry.fingerprint) })
|
toast.success(`Rejected ${entry.service}`, { description: shortFingerprint(entry.fingerprint) })
|
||||||
onDone()
|
onDone()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(`Failed to ignore ${entry.service}`, { description: errorMessage(error) })
|
toast.error(`Failed to reject ${entry.service}`, { description: errorMessage(error) })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -129,15 +158,16 @@ function PendingRow({ entry, onDone }: { entry: PeerPending; onDone: () => void
|
|||||||
{entry.service}
|
{entry.service}
|
||||||
</p>
|
</p>
|
||||||
<p className="truncate font-mono text-xs text-muted-foreground">{entry.fingerprint}</p>
|
<p className="truncate font-mono text-xs text-muted-foreground">{entry.fingerprint}</p>
|
||||||
|
<p className="text-xs text-muted-foreground/70">First seen {fmtTime(entry.first_seen)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
<Button size="xs" variant="outline" onPress={() => void deny.run()} isDisabled={deny.pending}>
|
<Button size="xs" variant="outline" onPress={() => void reject.run()} isDisabled={reject.pending}>
|
||||||
{deny.pending ? <LoaderCircle className="animate-spin" /> : <X />}
|
{reject.pending ? <LoaderCircle className="animate-spin" /> : <X />}
|
||||||
Ignore
|
Reject
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="xs" onPress={() => void approve.run()} isDisabled={approve.pending}>
|
<Button size="xs" onPress={() => void approve.run()} isDisabled={approve.pending}>
|
||||||
{approve.pending ? <LoaderCircle className="animate-spin" /> : <Check />}
|
{approve.pending ? <LoaderCircle className="animate-spin" /> : <Check />}
|
||||||
Authorize
|
Accept
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -159,13 +189,14 @@ function AuthorizedRow({ entry, onDone }: { entry: PeerAuthorized; onDone: () =>
|
|||||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2">
|
<div className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="flex items-center gap-1.5 text-sm font-medium">
|
<p className="flex items-center gap-1.5 text-sm font-medium">
|
||||||
<BadgeCheck className={cn('size-4', 'text-emerald-500')} aria-hidden />
|
<BadgeCheck className="size-4 text-emerald-500" aria-hidden />
|
||||||
{entry.service}
|
{entry.service}
|
||||||
</p>
|
</p>
|
||||||
<p className="truncate font-mono text-xs text-muted-foreground">
|
<p className="truncate font-mono text-xs text-muted-foreground">
|
||||||
{entry.fingerprint}
|
{entry.fingerprint}
|
||||||
<span className="ml-2 text-muted-foreground/70">by {entry.approved_by}</span>
|
<span className="ml-2 text-muted-foreground/70">by {entry.approved_by}</span>
|
||||||
</p>
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground/70">Accepted {fmtTime(entry.approved_at)}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
size="xs"
|
size="xs"
|
||||||
@@ -179,3 +210,46 @@ function AuthorizedRow({ entry, onDone }: { entry: PeerAuthorized; onDone: () =>
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RevokedRow({ entry, onDone }: { entry: PeerAuthorized; onDone: () => void }) {
|
||||||
|
const reauthorize = useAsyncAction(async () => {
|
||||||
|
try {
|
||||||
|
await approvePeer(entry.service, entry.fingerprint)
|
||||||
|
toast.success(`Re-authorized ${entry.service}`, {
|
||||||
|
description: shortFingerprint(entry.fingerprint),
|
||||||
|
})
|
||||||
|
onDone()
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(`Failed to re-authorize ${entry.service}`, { description: errorMessage(error) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-between gap-3 rounded-lg border border-border bg-muted/30 px-3 py-2'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground">
|
||||||
|
<X className="size-4 text-muted-foreground/60" aria-hidden />
|
||||||
|
{entry.service}
|
||||||
|
</p>
|
||||||
|
<p className="truncate font-mono text-xs text-muted-foreground/80">
|
||||||
|
{entry.fingerprint}
|
||||||
|
<span className="ml-2 text-muted-foreground/60">by {entry.approved_by}</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground/70">Revoked {fmtTime(entry.revoked_at)}</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="outline"
|
||||||
|
onPress={() => void reauthorize.run()}
|
||||||
|
isDisabled={reauthorize.pending}
|
||||||
|
>
|
||||||
|
{reauthorize.pending ? <LoaderCircle className="animate-spin" /> : <RotateCcw />}
|
||||||
|
Re-authorize
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,3 +37,8 @@ export function approvePeer(service: string, fingerprint: string): Promise<void>
|
|||||||
export function revokePeer(service: string, fingerprint: string): Promise<void> {
|
export function revokePeer(service: string, fingerprint: string): Promise<void> {
|
||||||
return post<void>('/api/peers/revoke', { service, fingerprint })
|
return post<void>('/api/peers/revoke', { service, fingerprint })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Rejects a pending first-contact request without authorizing it. */
|
||||||
|
export function rejectPeer(service: string, fingerprint: string): Promise<void> {
|
||||||
|
return post<void>('/api/peers/reject', { service, fingerprint })
|
||||||
|
}
|
||||||
|
|||||||
@@ -114,6 +114,26 @@ func (h *Handler) Revoke(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
|
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reject serves POST /api/peers/reject — denies a pending first-contact
|
||||||
|
// request without authorizing it (the auth service drops the request and
|
||||||
|
// audits the decision).
|
||||||
|
func (h *Handler) Reject(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var action authzAction
|
||||||
|
if !decodeAction(r, w, &action) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
client, err := h.ensureClient()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "auth management client unavailable: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := client.Reject(r.Context(), action.Service, action.Fingerprint); err != nil {
|
||||||
|
http.Error(w, "failed to reject peer: "+err.Error(), http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "rejected"})
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) ensureClient() (*authmgmt.Client, error) {
|
func (h *Handler) ensureClient() (*authmgmt.Client, error) {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
defer h.mu.Unlock()
|
defer h.mu.Unlock()
|
||||||
|
|||||||
@@ -204,6 +204,13 @@ func (c *Client) Revoke(ctx context.Context, service, fingerprint string) error
|
|||||||
authzRequest{Service: service, Fingerprint: fingerprint}, nil)
|
authzRequest{Service: service, Fingerprint: fingerprint}, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reject denies a pending first-contact request: the request is dropped and
|
||||||
|
// audited; the peer stays on the no-op path until the owner approves it.
|
||||||
|
func (c *Client) Reject(ctx context.Context, service, fingerprint string) error {
|
||||||
|
return c.do(ctx, http.MethodPost, "/internal/reject",
|
||||||
|
authzRequest{Service: service, Fingerprint: fingerprint}, nil)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) do(ctx context.Context, method, path string, body, out any) error {
|
func (c *Client) do(ctx context.Context, method, path string, body, out any) error {
|
||||||
var payload []byte
|
var payload []byte
|
||||||
if body != nil {
|
if body != nil {
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ func main() {
|
|||||||
mux.HandleFunc("/api/peers/attestation", peersHandler.Attestation)
|
mux.HandleFunc("/api/peers/attestation", peersHandler.Attestation)
|
||||||
mux.HandleFunc("/api/peers/approve", peersHandler.Approve)
|
mux.HandleFunc("/api/peers/approve", peersHandler.Approve)
|
||||||
mux.HandleFunc("/api/peers/revoke", peersHandler.Revoke)
|
mux.HandleFunc("/api/peers/revoke", peersHandler.Revoke)
|
||||||
|
mux.HandleFunc("/api/peers/reject", peersHandler.Reject)
|
||||||
|
|
||||||
frontendDir, err := fs.Sub(embeddedFrontend, "frontend/dist")
|
frontendDir, err := fs.Sub(embeddedFrontend, "frontend/dist")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user