feat(security): trusted-services surface + auth mgmt client (M2 §3)

Maestro is the UI/CA, not the enforcement: the auth service owns its
authorized-keys trust store. This is Maestro's side of the flow.

- lib/authmgmt: mTLS management client (Maestro's own identity cert from
  the instance CA) for /internal/{attestation,authorizations,revoke,
  pending}; RevokeServiceBestEffort for uninstall.
- handlers/peers: GET /api/peers, /api/peers/attestation, POST
  approve/revoke.
- uninstall: revoke the service's authorization in the auth trust store
  alongside the CRL identity revocation.
- frontend: TrustedServices panel (pending first-contact authorizations +
  approved keys with authorize/ignore/revoke actions).
- spec.yaml: document the new endpoints and schemas.
- housekeeping: untrack pki test log artifacts, gofmt fixes in pki.
This commit is contained in:
2026-08-15 23:27:21 +01:00
parent dc79629c7e
commit 950b6e5827
16 changed files with 839 additions and 28 deletions
+146
View File
@@ -0,0 +1,146 @@
// 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"})
}
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)
}