From 73fd96d69807d49759feb385dbb4f0b9f068a587 Mon Sep 17 00:00:00 2001 From: Admin <1+admin@noreply.git.dcntrlized.org> Date: Sun, 16 Aug 2026 22:25:34 +0100 Subject: [PATCH] =?UTF-8?q?feat(peers):=20full=20trust-store=20UI=20?= =?UTF-8?q?=E2=80=94=20accept,=20reject,=20revoke,=20re-authorize?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /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. --- spec.yaml | 23 ++++ .../components/service/trusted-services.tsx | 116 ++++++++++++++---- src/frontend/src/lib/api/peers.ts | 5 + src/handlers/peers/peers.go | 20 +++ src/lib/authmgmt/client.go | 7 ++ src/main.go | 1 + 6 files changed, 151 insertions(+), 21 deletions(-) diff --git a/spec.yaml b/spec.yaml index cfd5296..1e42a1d 100644 --- a/spec.yaml +++ b/spec.yaml @@ -546,3 +546,26 @@ paths: description: Missing service or fingerprint '502': 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 diff --git a/src/frontend/src/components/service/trusted-services.tsx b/src/frontend/src/components/service/trusted-services.tsx index 473d04a..5ebc4dc 100644 --- a/src/frontend/src/components/service/trusted-services.tsx +++ b/src/frontend/src/components/service/trusted-services.tsx @@ -16,11 +16,11 @@ */ 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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@components/ui/card' 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 { cn } from '@lib/utils' import type { PeerAuthorized, PeerPending, PeerTrustState } from '@lib/types' @@ -39,11 +39,19 @@ function shortFingerprint(fp: string): string { 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 - * authorizations and the approved trust store. Maestro is only the UI — the - * auth service owns the enforcement, so this panel is a convenience, not a - * security boundary. Hidden entirely when the management API is unreachable. + * authorizations, the approved trust store, and revoked keys. Maestro is only + * the UI — the auth service owns the enforcement, so this panel is a + * convenience, not a security boundary. Hidden entirely when the management + * API is unreachable. */ export function TrustedServices() { const [state, setState] = useState({ kind: 'loading' }) @@ -63,10 +71,13 @@ export function TrustedServices() { if (state.kind === 'loading') 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 authorized = state.state.authorized.filter((k) => !k.revoked_at) + const pending = state.state.pending ?? [] + 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 ( @@ -76,26 +87,44 @@ export function TrustedServices() { Trusted services - Services asking to talk to the auth service. Approve a fingerprint to authorize it (SSH - authorized_keys, applied to the service fleet); revoke cuts it off immediately. + Services asking to talk to the auth service. Accept authorizes a fingerprint (SSH + 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. {pending.length > 0 && ( +

+ Requested access ({pending.length}) +

{pending.map((p) => ( ))}
)} - {authorized.length > 0 && ( + {active.length > 0 && ( - {authorized.map((k) => ( +

+ Accepted ({active.length}) +

+ {active.map((k) => ( ))}
)} + + {revoked.length > 0 && ( + +

+ Revoked ({revoked.length}) +

+ {revoked.map((k) => ( + + ))} +
+ )}
) } @@ -111,13 +140,13 @@ function PendingRow({ entry, onDone }: { entry: PeerPending; onDone: () => void } }) - const deny = useAsyncAction(async () => { + const reject = useAsyncAction(async () => { try { - await revokePeer(entry.service, entry.fingerprint) - toast.success(`Ignored ${entry.service}`, { description: shortFingerprint(entry.fingerprint) }) + await rejectPeer(entry.service, entry.fingerprint) + toast.success(`Rejected ${entry.service}`, { description: shortFingerprint(entry.fingerprint) }) onDone() } 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.fingerprint}

+

First seen {fmtTime(entry.first_seen)}

-
@@ -159,13 +189,14 @@ function AuthorizedRow({ entry, onDone }: { entry: PeerAuthorized; onDone: () =>

- + {entry.service}

{entry.fingerprint} by {entry.approved_by}

+

Accepted {fmtTime(entry.approved_at)}

) } + +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 ( +
+
+

+ + {entry.service} +

+

+ {entry.fingerprint} + by {entry.approved_by} +

+

Revoked {fmtTime(entry.revoked_at)}

+
+ +
+ ) +} diff --git a/src/frontend/src/lib/api/peers.ts b/src/frontend/src/lib/api/peers.ts index 822c71f..75b6d03 100644 --- a/src/frontend/src/lib/api/peers.ts +++ b/src/frontend/src/lib/api/peers.ts @@ -37,3 +37,8 @@ export function approvePeer(service: string, fingerprint: string): Promise export function revokePeer(service: string, fingerprint: string): Promise { return post('/api/peers/revoke', { service, fingerprint }) } + +/** Rejects a pending first-contact request without authorizing it. */ +export function rejectPeer(service: string, fingerprint: string): Promise { + return post('/api/peers/reject', { service, fingerprint }) +} diff --git a/src/handlers/peers/peers.go b/src/handlers/peers/peers.go index 1758dfe..fab8d77 100644 --- a/src/handlers/peers/peers.go +++ b/src/handlers/peers/peers.go @@ -114,6 +114,26 @@ func (h *Handler) Revoke(w http.ResponseWriter, r *http.Request) { 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) { h.mu.Lock() defer h.mu.Unlock() diff --git a/src/lib/authmgmt/client.go b/src/lib/authmgmt/client.go index fd49e81..6f9aa41 100644 --- a/src/lib/authmgmt/client.go +++ b/src/lib/authmgmt/client.go @@ -204,6 +204,13 @@ func (c *Client) Revoke(ctx context.Context, service, fingerprint string) error 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 { var payload []byte if body != nil { diff --git a/src/main.go b/src/main.go index 8b1081d..4bb5007 100644 --- a/src/main.go +++ b/src/main.go @@ -113,6 +113,7 @@ func main() { mux.HandleFunc("/api/peers/attestation", peersHandler.Attestation) mux.HandleFunc("/api/peers/approve", peersHandler.Approve) mux.HandleFunc("/api/peers/revoke", peersHandler.Revoke) + mux.HandleFunc("/api/peers/reject", peersHandler.Reject) frontendDir, err := fs.Sub(embeddedFrontend, "frontend/dist") if err != nil {