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