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 && (
+