Files
Maestro/src/handlers/peers/peers.go
T
Admin 73fd96d698 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.
2026-08-17 00:27:42 +01:00

167 lines
5.3 KiB
Go

// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// Package peers serves the trusted-services surface: pending first-contact
// authorizations and the approve/revoke actions for the instance owner
// (security theme M2 §3). Maestro is the UI; the auth service owns the
// enforcement.
package peers
import (
"encoding/json"
"net/http"
"sync"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/authmgmt"
)
// Handler wraps a lazily-initialized auth management client so identity
// issuance happens once per process, not per request.
type Handler struct {
mu sync.Mutex
client *authmgmt.Client
}
// NewHandler creates the peers handler. The underlying mTLS client is
// constructed on first use.
func NewHandler() *Handler {
return &Handler{}
}
// List serves GET /api/peers — the combined pending + approved trust state.
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
client, err := h.ensureClient()
if err != nil {
http.Error(w, "auth management client unavailable: "+err.Error(), http.StatusInternalServerError)
return
}
state, err := client.TrustState(r.Context())
if err != nil {
http.Error(w, "failed to read trust state: "+err.Error(), http.StatusBadGateway)
return
}
writeJSON(w, http.StatusOK, state)
}
// Attestation serves GET /api/peers/attestation — the auth service's public
// signing key and its pinnable fingerprint.
func (h *Handler) Attestation(w http.ResponseWriter, r *http.Request) {
client, err := h.ensureClient()
if err != nil {
http.Error(w, "auth management client unavailable: "+err.Error(), http.StatusInternalServerError)
return
}
att, err := client.Attestation(r.Context())
if err != nil {
http.Error(w, "failed to read attestation: "+err.Error(), http.StatusBadGateway)
return
}
writeJSON(w, http.StatusOK, att)
}
type authzAction struct {
Service string `json:"service"`
Fingerprint string `json:"fingerprint"`
}
// Approve serves POST /api/peers/approve — forwards the owner's approval to
// the auth service's trust store.
func (h *Handler) Approve(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.Approve(r.Context(), action.Service, action.Fingerprint); err != nil {
http.Error(w, "failed to approve peer: "+err.Error(), http.StatusBadGateway)
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "approved"})
}
// Revoke serves POST /api/peers/revoke — forwards the owner's revocation
// (returns the peer to the no-op path immediately).
func (h *Handler) Revoke(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.Revoke(r.Context(), action.Service, action.Fingerprint); err != nil {
http.Error(w, "failed to revoke peer: "+err.Error(), http.StatusBadGateway)
return
}
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()
if h.client == nil {
client, err := authmgmt.New()
if err != nil {
return nil, err
}
h.client = client
}
return h.client, nil
}
func decodeAction(r *http.Request, w http.ResponseWriter, out *authzAction) bool {
if err := json.NewDecoder(r.Body).Decode(out); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return false
}
if out.Service == "" || out.Fingerprint == "" {
http.Error(w, "service and fingerprint are required", http.StatusBadRequest)
return false
}
return true
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}