v0.23.0: device-ROLE classification + tiered storage-wipe gate (user-data customer-confirmable; system/backup operator-only)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-11 21:29:18 +02:00
parent 9e3513557f
commit 15f7529a1c
10 changed files with 759 additions and 106 deletions
+125 -36
View File
@@ -27,10 +27,31 @@ type DiskOps interface {
}
// StorageGate authorizes a DESTRUCTIVE storage op (a data-bearing wipe/format) through the
// slice-4 reversibility gate. Satisfied by an adapter over reconcile.Gate in main.go. In 8C an
// unsigned destructive op returns (false, "pending_signature"); the signed path is slice 10.
// reversibility gate, TIERED by the agent's authoritative device-role verdict. Satisfied by an
// adapter over reconcile.Gate in main.go.
// - user-data → customer-confirmable: allowed iff the request carries the customer's confirmation
// bound to the device's durable id (no operator signature).
// - system/backup → operator-signature only (unsigned → pending_signature; Confirmed is ignored).
type StorageGate interface {
AuthorizeWipe(device string) (allowed bool, reason string)
AuthorizeWipe(req WipeRequest) WipeDecision
}
// WipeRequest is the inspection-derived input to the wipe gate. Role + DeviceDurableID are
// AGENT-INTERNAL (the agent classified the role and re-resolved the durable id); Confirmed +
// ConfirmDurableID are the caller's claim, honored ONLY for user-data and ONLY on a durable-id match.
type WipeRequest struct {
Role string // "system" | "backup" | "user-data" (agent-classified)
DeviceDurableID string // agent-re-resolved durable id of the device ("" if unresolvable)
Confirmed bool
ConfirmDurableID string
}
// WipeDecision is the gate's verdict.
type WipeDecision struct {
Allowed bool
Tier string // "customer_confirmable" | "destructive"
Reason string // machine reason (audit/UI)
NeedsConfirmation bool // user-data, not-yet-confirmed → ask the customer (NOT a signature)
}
// GuestLister lists the host's guests (to map a mount to the guests that depend on it for the
@@ -49,6 +70,11 @@ type DiskInfo struct {
BackingDevice string `json:"backing_device"` // /dev/sdb1, … ("" for network/lvm)
MountPath string `json:"mount_path"`
Class string `json:"class"` // fast | slow | ""
// Role is the agent's AUTHORITATIVE protection tier (system | backup | user-data), derived from
// the agent's own storage view + host topology — never from the controller. The controller drives
// the UI from it: system/backup get a lock badge and NO destructive controls; user-data is
// customer-manageable. Defense in depth — the agent re-enforces role at wipe time regardless.
Role string `json:"role"`
DataBearing bool `json:"data_bearing"` // agent device-inspection verdict (UI hint)
DataReason string `json:"data_reason,omitempty"`
// DurableID is the target's stable identity (e.g. "uuid:<fs-uuid>" for usb/local-dir). The
@@ -68,12 +94,16 @@ func (s *Server) handleDisks(w http.ResponseWriter, r *http.Request, vmid int) {
writeErr(w, http.StatusBadGateway, "could not read storage view")
return
}
// Resolve the OS/system disks ONCE for this request — role classification is agent-authoritative
// (the agent's own mount/topology read, never the caller's claim).
sysDisks, sysKnown := storage.SystemDisks(storage.NewProcHostReader())
out := make([]DiskInfo, 0, len(targets))
for _, t := range targets {
di := DiskInfo{
Name: t.Name, Type: t.Type, State: t.State,
BackingDevice: t.BackingDevice, MountPath: t.MountPath, Class: t.ClassHint,
DurableID: t.DurableID,
Role: string(storage.RoleForStorage(t.Type, t.BackingDevice, sysDisks, sysKnown)),
}
// Inspect the backing device for the UI's data-bearing hint (the authoritative check
// is re-run at format time on the actual device).
@@ -159,7 +189,14 @@ type formatRequest struct {
VMID int `json:"vmid"`
Device string `json:"device"`
FSType string `json:"fstype"`
// NOTE: any caller-supplied "blank"/"force" claim is deliberately IGNORED — the agent
// Confirmed + DurableID authorize a USER-DATA data-bearing wipe by the customer's informed-
// confirmation bound to the device's durable id. The agent RE-RESOLVES the device's durable id
// and matches it against DurableID — a confirmation for one disk can't wipe another. Both are
// INERT for system/backup devices (those stay operator-signature only — the role is the agent's,
// never the caller's, so confirmed:true on a system device is refused).
Confirmed bool `json:"confirmed"`
DurableID string `json:"durable_id"`
// NOTE: any caller-supplied "blank"/"force" claim is still deliberately IGNORED — the agent
// inspects the device itself (8C invariant).
}
@@ -174,7 +211,14 @@ type FormatResponse struct {
Formatted bool `json:"formatted"`
DataBearing bool `json:"data_bearing"`
Reason string `json:"reason"`
// PendingOp is set on a data-bearing refusal — the exact op to sign (slice 10B).
// Role is the agent's authoritative tier for the device (system | backup | user-data).
Role string `json:"role,omitempty"`
// NeedsConfirmation is set on a USER-DATA data-bearing refusal: the customer must re-submit with
// confirmed:true + DurableID (below) after the controller's type-to-confirm UI. NOT an operator
// signature — the customer authorizes the wipe of their own data drive.
NeedsConfirmation bool `json:"needs_confirmation,omitempty"`
DurableID string `json:"durable_id,omitempty"` // the durable id to confirm against (user-data)
// PendingOp is set on a SYSTEM/BACKUP data-bearing refusal — the exact op the operator must sign.
PendingOp *PendingOp `json:"pending_op,omitempty"`
}
@@ -217,44 +261,89 @@ func (s *Server) handleDiskFormat(w http.ResponseWriter, r *http.Request, vmid i
s.logger.Error("local-api: format device inspect", "device", req.Device, "err", err)
// inspect error → fail-safe data-bearing (probe.DataBearing() is true on !Probed)
}
if probe.DataBearing() {
// Destructive: route through the gate. With no operator signature → pending_signature.
allowed, reason := s.diskGate.AuthorizeWipe(req.Device)
if !allowed {
// Surface the bound op the operator must sign (slice 10B): derive the DURABLE device id
// so the signed wipe binds to this exact physical disk (not the mutable path), and the
// runner can re-resolve it at execution. A durable-id derivation failure is non-fatal —
// the refusal still stands; we just can't pre-fill the durable id.
var pending *PendingOp
if durableID, derr := storage.DeviceDurableID(req.Device); derr == nil {
pending = &PendingOp{Op: "storage_wipe", HostScope: s.hostID, DurableID: durableID, FSType: req.FSType}
s.logger.Warn("local-api: data-bearing format refused — PENDING OPERATOR SIGNATURE",
"vmid", vmid, "device", req.Device, "durable_id", durableID, "fstype", req.FSType,
"why", probe.Reason(), "to_authorize", "felhom-opsign -op storage_wipe -host "+s.hostID+" -durable-id "+durableID)
} else {
s.logger.Warn("local-api: data-bearing format refused (no durable id)",
"vmid", vmid, "device", req.Device, "why", probe.Reason(), "derive_err", derr)
}
writeStatus(w, http.StatusForbidden, false,
FormatResponse{VMID: vmid, Device: req.Device, Formatted: false, DataBearing: true, Reason: probe.Reason(), PendingOp: pending},
"device is data-bearing — format requires an operator signature ("+reason+")")
if !probe.DataBearing() {
// Blank device → benign → mkfs (role is irrelevant; there is nothing to destroy).
if err := s.disks.Format(r.Context(), req.Device, req.FSType); err != nil {
s.logger.Error("local-api: format", "vmid", vmid, "device", req.Device, "err", err)
writeErr(w, http.StatusBadGateway, "format failed: "+err.Error())
return
}
// A signed wipe is executed by the signed-jobs runner (queue → verify gate → durable
// re-resolve + re-inspect → mkfs), NOT this synchronous path. This branch (gate ALLOWED a
// data-bearing format inline) is unreachable: the inline path passes signed=nil → always
// pending. Fail safe.
writeErr(w, http.StatusForbidden, "data-bearing format must be completed via a signed job (felhom-opsign → hub queue)")
writeOK(w, FormatResponse{VMID: vmid, Device: req.Device, Formatted: true, DataBearing: false, Reason: "blank device formatted " + req.FSType})
return
}
// Blank device → benign → mkfs.
if err := s.disks.Format(r.Context(), req.Device, req.FSType); err != nil {
s.logger.Error("local-api: format", "vmid", vmid, "device", req.Device, "err", err)
writeErr(w, http.StatusBadGateway, "format failed: "+err.Error())
// Data-bearing → TIER by the agent's authoritative role classification (its own inspection,
// never the caller's claim). The agent also re-resolves the device's durable id; the customer's
// confirmation must bind to it.
role := s.deviceRole(r.Context(), req.Device)
deviceDurable, derr := storage.DeviceDurableID(req.Device)
if derr != nil {
deviceDurable = "" // refusal still stands; binding/pending-op just lack the id
}
dec := s.diskGate.AuthorizeWipe(WipeRequest{
Role: string(role), DeviceDurableID: deviceDurable,
Confirmed: req.Confirmed, ConfirmDurableID: req.DurableID,
})
if dec.Allowed {
// USER-DATA, customer-confirmed (durable-id-bound). The gate already AUDITED it. Wipe.
if err := s.disks.Format(r.Context(), req.Device, req.FSType); err != nil {
s.logger.Error("local-api: customer-confirmed format", "vmid", vmid, "device", req.Device, "err", err)
writeErr(w, http.StatusBadGateway, "format failed: "+err.Error())
return
}
s.logger.Warn("local-api: USER-DATA data-bearing format — CUSTOMER CONFIRMED (no operator signature)",
"vmid", vmid, "device", req.Device, "durable_id", deviceDurable, "fstype", req.FSType, "why", probe.Reason())
writeOK(w, FormatResponse{VMID: vmid, Device: req.Device, Formatted: true, DataBearing: true,
Role: string(role), DurableID: deviceDurable, Reason: "customer-confirmed wipe (" + probe.Reason() + ")"})
return
}
writeOK(w, FormatResponse{VMID: vmid, Device: req.Device, Formatted: true, DataBearing: false, Reason: "blank device formatted " + req.FSType})
if dec.Tier == "customer_confirmable" {
// USER-DATA refusal: either awaiting the customer's confirmation, or the confirmation didn't
// bind to THIS device. Surface the durable id to confirm against — NOT an operator signature.
msg := "device is data-bearing — customer confirmation required"
if !dec.NeedsConfirmation {
msg = "confirmation does not match this device — refused (" + dec.Reason + ")"
}
s.logger.Warn("local-api: user-data data-bearing format refused",
"vmid", vmid, "device", req.Device, "durable_id", deviceDurable, "reason", dec.Reason, "why", probe.Reason())
writeStatus(w, http.StatusForbidden, false,
FormatResponse{VMID: vmid, Device: req.Device, Formatted: false, DataBearing: true, Role: string(role),
NeedsConfirmation: dec.NeedsConfirmation, DurableID: deviceDurable, Reason: probe.Reason()}, msg)
return
}
// SYSTEM / BACKUP refusal: operator signature required. Surface the bound op to sign (the durable
// id binds the signed wipe to THIS exact physical disk). confirmed:true was ignored — by role.
var pending *PendingOp
if deviceDurable != "" {
pending = &PendingOp{Op: "storage_wipe", HostScope: s.hostID, DurableID: deviceDurable, FSType: req.FSType}
s.logger.Warn("local-api: protected (system/backup) data-bearing format refused — PENDING OPERATOR SIGNATURE",
"vmid", vmid, "device", req.Device, "role", role, "durable_id", deviceDurable, "fstype", req.FSType,
"why", probe.Reason(), "to_authorize", "felhom-opsign -op storage_wipe -host "+s.hostID+" -durable-id "+deviceDurable)
} else {
s.logger.Warn("local-api: protected data-bearing format refused (no durable id)",
"vmid", vmid, "device", req.Device, "role", role, "why", probe.Reason())
}
writeStatus(w, http.StatusForbidden, false,
FormatResponse{VMID: vmid, Device: req.Device, Formatted: false, DataBearing: true, Role: string(role), PendingOp: pending, Reason: probe.Reason()},
"device is system/backup-protected — format requires an operator signature ("+dec.Reason+")")
}
// deviceRole resolves a device's AUTHORITATIVE protection tier. It prefers a known storage target's
// role (so a PBS-backed device is recognized as backup), falling back to a raw-device classification
// (for a fresh disk not yet a PVE storage — the init flow). Defaults to system on ambiguity.
func (s *Server) deviceRole(ctx context.Context, device string) storage.DeviceRole {
sysDisks, sysKnown := storage.SystemDisks(storage.NewProcHostReader())
if targets, err := s.storage.Observe(ctx); err == nil {
for _, t := range targets {
if t.BackingDevice != "" && t.BackingDevice == device {
return storage.RoleForStorage(t.Type, t.BackingDevice, sysDisks, sysKnown)
}
}
}
return storage.RoleForRawDevice(device, sysDisks, sysKnown)
}
// dependentGuests returns the VMIDs whose config has a mount whose storage backs the ejected
+93 -34
View File
@@ -52,14 +52,21 @@ func (f *fakeDiskOps) Unmount(_ context.Context, where string) error {
func (f *fakeDiskOps) formatted() []string { f.mu.Lock(); defer f.mu.Unlock(); return append([]string(nil), f.formatCalls...) }
type fakeGate struct {
allowed bool
reason string
calls []string
mu sync.Mutex
decision WipeDecision
reqs []WipeRequest
}
func (g *fakeGate) AuthorizeWipe(device string) (bool, string) {
g.calls = append(g.calls, device)
return g.allowed, g.reason
func (g *fakeGate) AuthorizeWipe(req WipeRequest) WipeDecision {
g.mu.Lock()
defer g.mu.Unlock()
g.reqs = append(g.reqs, req)
return g.decision
}
func (g *fakeGate) requests() []WipeRequest {
g.mu.Lock()
defer g.mu.Unlock()
return append([]WipeRequest(nil), g.reqs...)
}
type fakeGuestList struct{ guests []proxmox.Guest }
@@ -96,7 +103,7 @@ func newDiskServer(t *testing.T, d *fakeDiskOps, g *fakeGate, sv StorageView, gl
// A blank device (the agent's own probe says blank) is formatted — mkfs called, gate NOT consulted.
func TestFormat_BlankDevice_Formats(t *testing.T) {
d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true}} // blank: Probed, nothing set
g := &fakeGate{allowed: false, reason: "pending_signature"}
g := &fakeGate{}
h := newDiskServer(t, d, g, nil, nil)
w := do(t, h, "POST", "/disks/format", "A", `{"device":"/dev/sdb","fstype":"ext4"}`)
@@ -106,39 +113,106 @@ func TestFormat_BlankDevice_Formats(t *testing.T) {
if got := d.formatted(); len(got) != 1 || got[0] != "/dev/sdb" {
t.Fatalf("mkfs not called for blank device: %v", got)
}
if len(g.calls) != 0 {
if len(g.requests()) != 0 {
t.Fatal("gate was consulted for a blank-device format (should be benign)")
}
}
// THE HEADLINE 8C TEST: a caller asks to format a DATA-BEARING device. The agent inspects the
// device itself, classifies it destructive, the gate refuses pending_signature, and **mkfs is
// NEVER called** — the caller's intent cannot wipe data-bearing storage.
func TestFormat_DataBearingDevice_RefusedNoMkfs(t *testing.T) {
// THE HEADLINE SECURITY TEST: a caller asks to format a DATA-BEARING device the gate tiers as
// SYSTEM/BACKUP (destructive). The gate refuses pending_signature, **mkfs is NEVER called**, and the
// response surfaces the operator-signature pending op (NOT a customer-confirmation prompt).
func TestFormat_DataBearing_ProtectedRole_RefusedNoMkfs(t *testing.T) {
d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true, HasFilesystem: true, FSType: "ext4"}}
g := &fakeGate{allowed: false, reason: "pending_signature"}
g := &fakeGate{decision: WipeDecision{Allowed: false, Tier: "destructive", Reason: "pending_signature"}}
h := newDiskServer(t, d, g, nil, nil)
w := do(t, h, "POST", "/disks/format", "A", `{"device":"/dev/sdb","fstype":"ext4"}`)
if w.Code != http.StatusForbidden {
t.Fatalf("data-bearing format: got %d want 403", w.Code)
t.Fatalf("data-bearing protected format: got %d want 403", w.Code)
}
if got := d.formatted(); len(got) != 0 {
t.Fatalf("mkfs WAS called on a data-bearing device — security invariant violated: %v", got)
t.Fatalf("mkfs WAS called on a protected data-bearing device — security invariant violated: %v", got)
}
if len(g.calls) != 1 || g.calls[0] != "/dev/sdb" {
t.Fatalf("gate not consulted for the destructive format: %v", g.calls)
if len(g.requests()) != 1 {
t.Fatalf("gate not consulted for the destructive format: %v", g.requests())
}
if !strings.Contains(w.Body.String(), "operator signature") {
t.Fatalf("response did not signal an operator signature is needed: %s", w.Body.String())
}
}
// A hand-issued confirmed:true on a data-bearing device the gate tiers as SYSTEM/BACKUP is STILL
// refused (operator-signature) and mkfs is NOT called — role beats confirmation. (The gate's
// role-tiering is itself asserted in reconcile/gate_test; here we assert the handler honors a
// destructive verdict even when the caller claimed confirmed:true.)
func TestFormat_DataBearing_ConfirmedTrueButProtected_StillRefused(t *testing.T) {
d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true, HasFilesystem: true, FSType: "ext4"}}
g := &fakeGate{decision: WipeDecision{Allowed: false, Tier: "destructive", Reason: "pending_signature"}}
h := newDiskServer(t, d, g, nil, nil)
w := do(t, h, "POST", "/disks/format", "A", `{"device":"/dev/sdb","fstype":"ext4","confirmed":true,"durable_id":"byid:wwn-xyz"}`)
if w.Code != http.StatusForbidden {
t.Fatalf("confirmed-but-protected format: got %d want 403", w.Code)
}
if got := d.formatted(); len(got) != 0 {
t.Fatalf("mkfs called despite protected role — confirmation must not beat role: %v", got)
}
// The handler must have forwarded the caller's confirmation claim to the gate (the gate, not the
// handler, ignores it for protected roles).
if reqs := g.requests(); len(reqs) != 1 || !reqs[0].Confirmed || reqs[0].ConfirmDurableID != "byid:wwn-xyz" {
t.Fatalf("handler did not forward the confirmation claim to the gate: %+v", reqs)
}
}
// USER-DATA + customer-confirmed (gate Allowed): the wipe proceeds — mkfs IS called, 200.
func TestFormat_DataBearing_UserDataConfirmed_Formats(t *testing.T) {
d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true, HasFilesystem: true, FSType: "ext4"}}
g := &fakeGate{decision: WipeDecision{Allowed: true, Tier: "customer_confirmable", Reason: "customer_confirmed"}}
h := newDiskServer(t, d, g, nil, nil)
w := do(t, h, "POST", "/disks/format", "A", `{"device":"/dev/sdb","fstype":"ext4","confirmed":true,"durable_id":"byuuid:abc"}`)
if w.Code != http.StatusOK {
t.Fatalf("user-data confirmed format: got %d want 200 (%s)", w.Code, w.Body.String())
}
if got := d.formatted(); len(got) != 1 || got[0] != "/dev/sdb" {
t.Fatalf("mkfs not called for a customer-confirmed user-data wipe: %v", got)
}
}
// USER-DATA + NOT yet confirmed (gate refuses pending_confirmation): 403 with needs_confirmation,
// mkfs NOT called, and NO operator-signature pending op (it's the customer's to confirm).
func TestFormat_DataBearing_UserDataNeedsConfirmation(t *testing.T) {
d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true, HasFilesystem: true, FSType: "ext4"}}
g := &fakeGate{decision: WipeDecision{Allowed: false, Tier: "customer_confirmable", Reason: "pending_confirmation", NeedsConfirmation: true}}
h := newDiskServer(t, d, g, nil, nil)
w := do(t, h, "POST", "/disks/format", "A", `{"device":"/dev/sdb","fstype":"ext4"}`)
if w.Code != http.StatusForbidden {
t.Fatalf("user-data unconfirmed: got %d want 403", w.Code)
}
if len(d.formatted()) != 0 {
t.Fatal("mkfs called for an unconfirmed user-data wipe")
}
var resp struct {
Data FormatResponse `json:"data"`
}
_ = json.Unmarshal(w.Body.Bytes(), &resp)
if !resp.Data.NeedsConfirmation {
t.Fatalf("response missing needs_confirmation: %s", w.Body.String())
}
if resp.Data.PendingOp != nil {
t.Fatal("user-data refusal must NOT surface an operator-signature pending op")
}
if strings.Contains(w.Body.String(), "operator signature") {
t.Fatalf("user-data refusal must not ask for an operator signature: %s", w.Body.String())
}
}
// Fail-safe: a device the agent could NOT reliably inspect (Probed=false) is treated as
// data-bearing → refused, mkfs not called.
// data-bearing → routed through the gate, mkfs not called.
func TestFormat_AmbiguousProbe_TreatedDestructive(t *testing.T) {
d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: false}} // probe failed → DataBearing()=true
g := &fakeGate{allowed: false, reason: "pending_signature"}
g := &fakeGate{decision: WipeDecision{Allowed: false, Tier: "destructive", Reason: "pending_signature"}}
h := newDiskServer(t, d, g, nil, nil)
w := do(t, h, "POST", "/disks/format", "A", `{"device":"/dev/sdb","fstype":"ext4"}`)
@@ -150,21 +224,6 @@ func TestFormat_AmbiguousProbe_TreatedDestructive(t *testing.T) {
}
}
// Even a (hypothetical) gate that ALLOWS does not format a data-bearing device in 8C (the signed
// completion is slice 10).
func TestFormat_DataBearing_GateAllows_StillNoMkfsIn8C(t *testing.T) {
d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true, HasPartitionTable: true}}
g := &fakeGate{allowed: true, reason: "signed"}
h := newDiskServer(t, d, g, nil, nil)
w := do(t, h, "POST", "/disks/format", "A", `{"device":"/dev/sdb","fstype":"ext4"}`)
if w.Code != http.StatusForbidden {
t.Fatalf("got %d want 403 (8C never formats data-bearing)", w.Code)
}
if len(d.formatted()) != 0 {
t.Fatal("mkfs called on a data-bearing device even though 8C must refuse")
}
}
func TestFormat_RejectsBadDeviceOrFSType(t *testing.T) {
d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true}}
h := newDiskServer(t, d, &fakeGate{}, nil, nil)