fleet-manager: checkpoint 3 — authorise pillar (broker fold-in, births inventory)
The AUTHORISE pillar: the manager owns the enrolment queue UX, the enrol-broker
stays the state machine + step-ca signer (v1 'alongside' fold-in).
enrol-broker (minimal, backward-compatible change):
- admin approve/deny now return the decided Enrolment as JSON when the caller
sends Accept: application/json. Browsers still redirect; other API callers
(verify-serving.sh) still get 204. The cert PEM stays unexported (only the
requesting host gets it via /status) — the JSON carries cert_serial, hostnqn,
context, fingerprint, cn.
fleet-manager:
- broker.go: mTLS client for the broker admin plane (Pending/Approve/Deny),
nil when unconfigured so the manager runs standalone.
- admin.go: GET /api/enrol/pending (proxy), POST /api/enrol/{id}/approve
(drives broker, then BIRTHS the inventory record — identity from the returned
serial/hostnqn/context, binding from the operator's optional body, enrolled
with the HUMAN approver = the manager's admin mTLS CN), POST .../deny.
503 when the broker isn't wired.
Test: httptest mock broker asserts the Accept:application/json approve, pending
proxy, inventory birth (identity+binding+enrolled), and the disabled-503 path.
Broker + manager both build/vet/test/gofmt clean.
This commit is contained in:
@@ -9,6 +9,8 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
@@ -27,7 +29,12 @@ func (s *server) routes(mux *http.ServeMux) {
|
||||
// LIFECYCLE — set server-authoritative desired-state for one host.
|
||||
mux.HandleFunc("PUT /api/hosts/{serial}/desired", s.handleSetDesired)
|
||||
|
||||
// AUTHORISE routes (/api/enrol/*) — checkpoint 3.
|
||||
// AUTHORISE — enrolment queue; approval drives the broker and BIRTHS the
|
||||
// inventory record (identity/binding/enrolled).
|
||||
mux.HandleFunc("GET /api/enrol/pending", s.handleEnrolPending)
|
||||
mux.HandleFunc("POST /api/enrol/{id}/approve", s.handleEnrolApprove)
|
||||
mux.HandleFunc("POST /api/enrol/{id}/deny", s.handleEnrolDeny)
|
||||
|
||||
// METRICS routes (/api/hosts/{serial}/metrics) — checkpoint 4.
|
||||
}
|
||||
|
||||
@@ -75,6 +82,77 @@ func (s *server) handleSetDesired(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"host": h, "pushed_live": pushed})
|
||||
}
|
||||
|
||||
// ---- AUTHORISE (enrolment queue, via the broker) --------------------------
|
||||
|
||||
func (s *server) handleEnrolPending(w http.ResponseWriter, r *http.Request) {
|
||||
if s.broker == nil {
|
||||
writeErr(w, http.StatusServiceUnavailable, "authorise plane not configured (no enrol-broker wired)")
|
||||
return
|
||||
}
|
||||
pending, err := s.broker.Pending()
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadGateway, "broker: "+err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"pending": pending})
|
||||
}
|
||||
|
||||
// handleEnrolApprove drives the broker's approve (which signs the CSR) and BIRTHS
|
||||
// the inventory record from the returned enrolment + the optional binding the
|
||||
// human records at approval time. The approver is THIS admin's client-cert CN
|
||||
// (the human at the SPA), not the broker's view of the manager service.
|
||||
func (s *server) handleEnrolApprove(w http.ResponseWriter, r *http.Request) {
|
||||
if s.broker == nil {
|
||||
writeErr(w, http.StatusServiceUnavailable, "authorise plane not configured (no enrol-broker wired)")
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
|
||||
// Optional binding (asset tag / switch port / location) — the physical↔crypto
|
||||
// tie the operator records. Empty body is fine.
|
||||
var binding Binding
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&binding); err != nil && err != io.EOF {
|
||||
writeErr(w, http.StatusBadRequest, "invalid binding JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
e, err := s.broker.Approve(id)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadGateway, "broker approve: "+err.Error())
|
||||
return
|
||||
}
|
||||
if e.CertSerial == "" {
|
||||
writeErr(w, http.StatusBadGateway, "broker approved but returned no cert serial")
|
||||
return
|
||||
}
|
||||
|
||||
host := &Host{
|
||||
Identity: Identity{CertSerial: e.CertSerial, Hostnqn: e.Hostnqn, MAC: e.Context.MAC, EKHash: e.Context.EKHash},
|
||||
Binding: binding,
|
||||
Enrolled: Enrolled{Approver: adminCN(r), At: time.Now().UTC(), Fingerprint: e.Fingerprint},
|
||||
}
|
||||
if err := s.store.Upsert(host); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "inventory birth: "+err.Error())
|
||||
return
|
||||
}
|
||||
log.Printf("audit ENROL-APPROVE serial=%s hostnqn=%s approver=%q enrol-id=%s", e.CertSerial, e.Hostnqn, adminCN(r), id)
|
||||
|
||||
born, _ := s.store.Get(e.CertSerial)
|
||||
writeJSON(w, http.StatusOK, born)
|
||||
}
|
||||
|
||||
func (s *server) handleEnrolDeny(w http.ResponseWriter, r *http.Request) {
|
||||
if s.broker == nil {
|
||||
writeErr(w, http.StatusServiceUnavailable, "authorise plane not configured (no enrol-broker wired)")
|
||||
return
|
||||
}
|
||||
if err := s.broker.Deny(r.PathValue("id")); err != nil {
|
||||
writeErr(w, http.StatusBadGateway, "broker deny: "+err.Error())
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// adminCN returns the CN of the admin's mTLS client cert, for audit attribution.
|
||||
// In insecure dev mode there is no peer cert, so it reports a sentinel.
|
||||
func adminCN(r *http.Request) string {
|
||||
|
||||
Reference in New Issue
Block a user