- /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.
167 lines
5.3 KiB
Go
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)
|
|
}
|