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:
@@ -0,0 +1,103 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// mockBroker stands in for the enrol-broker admin plane. It asserts the manager
|
||||
// asks for JSON on approve, and returns a decided Enrolment.
|
||||
func mockBroker(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /pending", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, []brokerEnrolment{{
|
||||
ID: "enr-1", State: "pending", Fingerprint: "amber-castle-river-delta-orbit-nova",
|
||||
CN: "fleet-host", PubKeyHash: "pk123", Context: brokerCtxContext{MAC: "de:ad:be:ef:00:09", EKHash: "ek-abc"},
|
||||
}})
|
||||
})
|
||||
mux.HandleFunc("POST /approve/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.Contains(r.Header.Get("Accept"), "application/json") {
|
||||
t.Errorf("approve did not send Accept: application/json (got %q)", r.Header.Get("Accept"))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, brokerEnrolment{
|
||||
ID: r.PathValue("id"), State: "approved",
|
||||
Fingerprint: "amber-castle-river-delta-orbit-nova", CN: "fleet-host",
|
||||
CertSerial: "beef0009", Hostnqn: "nqn.2026-06.org.example.fleet:host-beef0009",
|
||||
Context: brokerCtxContext{MAC: "de:ad:be:ef:00:09", EKHash: "ek-abc"},
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("POST /deny/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
return httptest.NewServer(mux)
|
||||
}
|
||||
|
||||
func TestAuthoriseApproveBirthsInventory(t *testing.T) {
|
||||
bs := mockBroker(t)
|
||||
defer bs.Close()
|
||||
|
||||
store, _ := newJSONStore("")
|
||||
s := &server{store: store, broker: &brokerClient{base: bs.URL, hc: bs.Client()}}
|
||||
mux := http.NewServeMux()
|
||||
s.routes(mux)
|
||||
|
||||
// pending proxies through
|
||||
pw := httptest.NewRecorder()
|
||||
mux.ServeHTTP(pw, httptest.NewRequest(http.MethodGet, "/api/enrol/pending", nil))
|
||||
if pw.Code != http.StatusOK {
|
||||
t.Fatalf("pending -> %d", pw.Code)
|
||||
}
|
||||
var plist struct {
|
||||
Pending []brokerEnrolment `json:"pending"`
|
||||
}
|
||||
json.Unmarshal(pw.Body.Bytes(), &plist)
|
||||
if len(plist.Pending) != 1 || plist.Pending[0].ID != "enr-1" {
|
||||
t.Fatalf("pending list = %+v", plist.Pending)
|
||||
}
|
||||
|
||||
// approve with a binding -> inventory record born
|
||||
aw := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/enrol/enr-1/approve",
|
||||
strings.NewReader(`{"asset_tag":"KIOSK-9","switch_port":"sw1/9","location":"Bay 3"}`))
|
||||
mux.ServeHTTP(aw, req)
|
||||
if aw.Code != http.StatusOK {
|
||||
t.Fatalf("approve -> %d: %s", aw.Code, aw.Body.String())
|
||||
}
|
||||
|
||||
h, ok := store.Get("beef0009")
|
||||
if !ok {
|
||||
t.Fatal("inventory record was not born on approve")
|
||||
}
|
||||
if h.Identity.Hostnqn != "nqn.2026-06.org.example.fleet:host-beef0009" {
|
||||
t.Errorf("hostnqn = %q", h.Identity.Hostnqn)
|
||||
}
|
||||
if h.Identity.MAC != "de:ad:be:ef:00:09" || h.Identity.EKHash != "ek-abc" {
|
||||
t.Errorf("identity context not carried: %+v", h.Identity)
|
||||
}
|
||||
if h.Binding.AssetTag != "KIOSK-9" || h.Binding.SwitchPort != "sw1/9" || h.Binding.Location != "Bay 3" {
|
||||
t.Errorf("binding not recorded: %+v", h.Binding)
|
||||
}
|
||||
if h.Enrolled.Fingerprint != "amber-castle-river-delta-orbit-nova" {
|
||||
t.Errorf("fingerprint = %q", h.Enrolled.Fingerprint)
|
||||
}
|
||||
if h.Enrolled.Approver != "dev-insecure" { // no client cert in the test request
|
||||
t.Errorf("approver = %q, want the manager admin CN", h.Enrolled.Approver)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthoriseDisabledWhenNoBroker(t *testing.T) {
|
||||
store, _ := newJSONStore("")
|
||||
s := &server{store: store, broker: nil} // authorise not configured
|
||||
mux := http.NewServeMux()
|
||||
s.routes(mux)
|
||||
|
||||
rw := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rw, httptest.NewRequest(http.MethodGet, "/api/enrol/pending", nil))
|
||||
if rw.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("expected 503 when no broker, got %d", rw.Code)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user