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:
Audiobuzz
2026-07-04 03:51:26 +00:00
parent 80cbb833cc
commit df4fa95bdd
6 changed files with 367 additions and 14 deletions
+20 -7
View File
@@ -15,6 +15,7 @@ import (
"log"
"net/http"
"os"
"strings"
"time"
)
@@ -105,7 +106,7 @@ func (b *broker) handleApprove(w http.ResponseWriter, r *http.Request) {
return
}
log.Printf("audit APPROVE id=%s cn=%q serial=%s hostnqn=%s admin=%q", e.ID, e.CN, e.CertSerial, e.Hostnqn, admin)
redirectOrJSON(w, r)
respondDecision(w, r, e)
}
func (b *broker) handleDeny(w http.ResponseWriter, r *http.Request) {
@@ -117,16 +118,28 @@ func (b *broker) handleDeny(w http.ResponseWriter, r *http.Request) {
return
}
log.Printf("audit DENY id=%s cn=%q admin=%q", e.ID, e.CN, admin)
redirectOrJSON(w, r)
respondDecision(w, r, e)
}
// redirectOrJSON sends a browser form back to the queue, or a 204 for API callers.
func redirectOrJSON(w http.ResponseWriter, r *http.Request) {
if isBrowser(r) {
// respondDecision replies to an approve/deny. A browser form gets redirected back
// to the queue; a caller that asks for JSON (Accept: application/json — e.g. the
// fleet-manager driving the broker as its authorise plane) gets the decided
// Enrolment (cert_serial, hostnqn, context, fingerprint — NOT the cert PEM, which
// is unexported and only reaches the requesting host); any other API caller keeps
// the original 204 (so verify-serving.sh and existing scripts are unaffected).
func respondDecision(w http.ResponseWriter, r *http.Request, e *Enrolment) {
switch {
case isBrowser(r):
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
case wantsJSON(r):
writeJSON(w, http.StatusOK, e)
default:
w.WriteHeader(http.StatusNoContent)
}
}
func wantsJSON(r *http.Request) bool {
return strings.Contains(r.Header.Get("Accept"), "application/json")
}
func isBrowser(r *http.Request) bool {
+18 -3
View File
@@ -63,9 +63,17 @@ inspectable, reproducible; a DB swap is a drop-in behind the `Store` interface.
| `GET /api/hosts` | ✅ ckpt 1 — fleet overview |
| `GET /api/hosts/{serial}` | ✅ ckpt 1 — host detail |
| `PUT /api/hosts/{serial}/desired` | ✅ ckpt 1 — set desired-state (audited to admin CN) |
| `GET /api/enrol/pending`, `POST /api/enrol/{id}/approve\|deny` | ⏳ ckpt 3 (drives the broker) |
| `GET /api/enrol/pending` | ✅ ckpt 3 — proxies the broker queue |
| `POST /api/enrol/{id}/approve` | ✅ ckpt 3 — signs via broker + **births inventory** (identity/binding/enrolled) |
| `POST /api/enrol/{id}/deny` | ✅ ckpt 3 |
| `GET /api/hosts/{serial}/metrics` | ⏳ ckpt 4 (InfluxDB/Flux) |
The authorise pillar drives the enrol-broker's admin plane over mTLS (the manager
is a service client, gated by the dedicated admin CA) asking for JSON, so an
approve returns the decided enrolment; the manager records the **human** approver
(its own admin mTLS CN) and births the inventory record. The broker stays the
enrolment state machine + step-ca signer (v1 "alongside" fold-in).
**Agent plane** — gRPC `FleetAgent.Session` bidi over mTLS (fleet CA): ✅ ckpt 2.
`Hello`/`HealthSnapshot`/`CommandResult` up; `DesiredState`/`Command` down.
Identity is the peer cert serial (never self-asserted); an unenrolled serial is
@@ -84,6 +92,9 @@ inventory for offline delivery. Metrics writing is behind a `MetricsSink`
| `FLEET_MGR_AGENT_ADDR` | `127.0.0.1:9600` | gRPC agent listener |
| `FLEET_MGR_AGENT_CERT` / `_KEY` | — | agent-plane server cert/key |
| `FLEET_MGR_AGENT_CLIENT_CA` | — | **fleet CA** (verifies host TPM certs). Agent plane starts only when all three are set. |
| `FLEET_MGR_BROKER_URL` | — | enrol-broker admin base URL (e.g. `https://127.0.0.1:9443`). Authorise pillar enabled only when set. |
| `FLEET_MGR_BROKER_CLIENT_CERT` / `_KEY` | — | manager's client cert for the broker admin mTLS (admin CA) |
| `FLEET_MGR_BROKER_CA` | — | CA that signs the broker's admin server cert |
| `FLEET_MGR_INVENTORY` | `/var/lib/fleet-manager/inventory.json` | inventory file (mount a volume) |
| `FLEET_MGR_SEED` | `false` | inject demo hosts so the SPA has data — dev only |
| `HOSTNQN_BASE` | `nqn.2026-06.org.example.fleet:host-` | hostnqn prefix (match the broker) |
@@ -106,5 +117,9 @@ curl -s localhost:9500/api/hosts | jq '.hosts[] | {name:.identity.starship_name,
- **Checkpoint 2 (done):** gRPC agent plane — proto contract, mTLS (fleet CA)
server, stream registry, identity-from-cert, snapshot→inventory, live
desired-state push, `MetricsSink` seam. Full mTLS integration test.
- Next: authorise/broker fold-in (ckpt 3) → InfluxDB read/write (ckpt 4) → themed
SPA (ckpt 5) → Docker/compose + PKI + docs (ckpt 6).
- **Checkpoint 3 (done):** authorise pillar — the enrol-broker gained an
`Accept`-gated JSON approve response (browsers/`verify-serving.sh` unchanged);
the manager drives it over mTLS and births the inventory record on approve.
httptest-mock-broker test.
- Next: InfluxDB read/write (ckpt 4) → themed SPA (ckpt 5) → Docker/compose + PKI
+ docs (ckpt 6).
+79 -1
View File
@@ -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 {
+121
View File
@@ -0,0 +1,121 @@
// broker.go — the manager's client for the enrol-broker's admin plane.
//
// The v1 "alongside" fold-in (per the plan): the manager owns the AUTHORISE UX
// (the SPA's queue + approve/deny), but the enrol-broker remains the enrolment
// state machine + step-ca signer. The manager drives the broker's admin API over
// mTLS as a service client (the dedicated admin CA gates it, same as a human
// admin), asking for JSON (Accept: application/json) so an approve returns the
// decided Enrolment — cert_serial + hostnqn + context + fingerprint — from which
// the manager BIRTHS the inventory record. Full absorption into one process is a
// later step; this keeps the bench-critical broker untouched.
package main
import (
"crypto/tls"
"crypto/x509"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
)
// brokerEnrolment is the subset of the broker's Enrolment JSON we consume.
type brokerEnrolment struct {
ID string `json:"id"`
State string `json:"state"`
Fingerprint string `json:"fingerprint"`
CN string `json:"cn"`
PubKeyHash string `json:"pubkey_hash"`
CertSerial string `json:"cert_serial"`
Hostnqn string `json:"hostnqn"`
Context brokerCtxContext `json:"context"`
CreatedAt time.Time `json:"created_at"`
DecidedBy string `json:"decided_by"`
}
type brokerCtxContext struct {
MAC string `json:"mac"`
DMIUUID string `json:"dmi_uuid"`
DMISerial string `json:"dmi_serial"`
EKHash string `json:"ek_hash"`
}
type brokerClient struct {
base string
hc *http.Client
}
// newBrokerClient builds the mTLS client, or returns nil (authorise disabled) if
// no broker URL is configured — so the manager runs standalone in dev.
func newBrokerClient(cfg config) (*brokerClient, error) {
if cfg.brokerAdminURL == "" {
return nil, nil
}
cert, err := tls.LoadX509KeyPair(cfg.brokerClientCert, cfg.brokerClientKey)
if err != nil {
return nil, fmt.Errorf("broker client cert: %w", err)
}
caPEM, err := os.ReadFile(cfg.brokerCA)
if err != nil {
return nil, fmt.Errorf("broker CA: %w", err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(caPEM) {
return nil, fmt.Errorf("no certificates in broker CA %s", cfg.brokerCA)
}
return &brokerClient{
base: strings.TrimRight(cfg.brokerAdminURL, "/"),
hc: &http.Client{
Timeout: 40 * time.Second, // approve blocks on step-ca signing
Transport: &http.Transport{TLSClientConfig: &tls.Config{
Certificates: []tls.Certificate{cert},
RootCAs: pool,
MinVersion: tls.VersionTLS12,
}},
},
}, nil
}
func (c *brokerClient) Pending() ([]brokerEnrolment, error) {
var out []brokerEnrolment
if err := c.do(http.MethodGet, "/pending", &out); err != nil {
return nil, err
}
return out, nil
}
// Approve signs + decides at the broker and returns the decided Enrolment.
func (c *brokerClient) Approve(id string) (brokerEnrolment, error) {
var e brokerEnrolment
err := c.do(http.MethodPost, "/approve/"+url.PathEscape(id), &e)
return e, err
}
func (c *brokerClient) Deny(id string) error {
return c.do(http.MethodPost, "/deny/"+url.PathEscape(id), nil)
}
func (c *brokerClient) do(method, path string, out any) error {
req, err := http.NewRequest(method, c.base+path, nil)
if err != nil {
return err
}
req.Header.Set("Accept", "application/json")
resp, err := c.hc.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode >= 300 {
return fmt.Errorf("broker %s %s -> %s: %s", method, path, resp.Status, strings.TrimSpace(string(body)))
}
if out != nil && len(body) > 0 {
return json.Unmarshal(body, out)
}
return nil
}
+103
View File
@@ -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)
}
}
+23
View File
@@ -36,6 +36,12 @@ type config struct {
agentKey string
agentClientCA string // FLEET CA (verifies host TPM identity certs)
// enrol-broker admin plane (the authorise pillar drives it over mTLS).
brokerAdminURL string
brokerClientCert string
brokerClientKey string
brokerCA string
inventoryPath string
seed bool
hostnqnBase string
@@ -54,6 +60,11 @@ func loadConfig() config {
agentKey: env("FLEET_MGR_AGENT_KEY", ""),
agentClientCA: env("FLEET_MGR_AGENT_CLIENT_CA", ""),
brokerAdminURL: env("FLEET_MGR_BROKER_URL", ""),
brokerClientCert: env("FLEET_MGR_BROKER_CLIENT_CERT", ""),
brokerClientKey: env("FLEET_MGR_BROKER_CLIENT_KEY", ""),
brokerCA: env("FLEET_MGR_BROKER_CA", ""),
inventoryPath: env("FLEET_MGR_INVENTORY", "/var/lib/fleet-manager/inventory.json"),
seed: env("FLEET_MGR_SEED", "") == "true",
hostnqnBase: env("HOSTNQN_BASE", "nqn.2026-06.org.example.fleet:host-"),
@@ -65,6 +76,7 @@ type server struct {
cfg config
store Store
hub *agentHub // live agent streams; nil if the agent plane isn't configured
broker *brokerClient // enrol-broker admin client; nil if authorise isn't configured
}
func main() {
@@ -83,6 +95,17 @@ func main() {
s := &server{cfg: cfg, store: store}
// Authorise pillar: the enrol-broker admin client (nil = disabled, so the
// manager runs standalone before a broker is wired).
broker, err := newBrokerClient(cfg)
if err != nil {
log.Fatalf("broker client: %v", err)
}
s.broker = broker
if broker == nil {
log.Printf("authorise pillar DISABLED (set FLEET_MGR_BROKER_URL + client cert/key + CA to enable)")
}
// Agent plane (gRPC/mTLS, fleet CA). Started only when configured; the admin
// plane can run standalone (e.g. before any host is enrolled). The hub is the
// live-stream registry the lifecycle API pushes desired-state through.