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:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user