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