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:
@@ -49,6 +49,23 @@ const (
|
||||
Benign Disposition = "benign"
|
||||
// Destructive — an operator signature bound to the action is REQUIRED.
|
||||
Destructive Disposition = "destructive"
|
||||
// CustomerConfirmable — a USER-DATA storage wipe: authorized by the customer's informed-
|
||||
// confirmation bound to the device's durable id, NOT an operator signature. This tier is
|
||||
// reachable ONLY for ClassStorageWipe on an agent-classified user-data device (see
|
||||
// Gate.AuthorizeStorageWipe). A user-data drive is already within the in-guest controller's
|
||||
// blast radius (it bind-mounts /mnt), so customer-confirmation adds no new reach — the data was
|
||||
// already destroyable. Every OTHER destructive class keeps the operator signature.
|
||||
CustomerConfirmable Disposition = "customer_confirmable"
|
||||
)
|
||||
|
||||
// Storage ROLE values — the protection tier the AGENT assigns a device by its own inspection
|
||||
// (mirrors storage.DeviceRole). Kept as plain strings here to avoid a storage→reconcile import edge:
|
||||
// the localapi adapter passes the agent's classification through verbatim. The gate NEVER derives the
|
||||
// role from a caller's claim — it only consumes the agent's verdict.
|
||||
const (
|
||||
StorageRoleSystem = "system"
|
||||
StorageRoleBackup = "backup"
|
||||
StorageRoleUserData = "user-data"
|
||||
)
|
||||
|
||||
// Provenance is AGENT-INTERNAL evidence that an otherwise-destructive action is
|
||||
|
||||
@@ -53,7 +53,11 @@ const (
|
||||
ReasonPendingSignature RefuseReason = "pending_signature" // destructive, no/again-needed signature
|
||||
ReasonRejected RefuseReason = "rejected" // signature failed authz verification
|
||||
ReasonRoleDenied RefuseReason = "role_denied" // signer role not authorized for this op class
|
||||
ReasonBindingMismatch RefuseReason = "binding_mismatch" // signature is for a different action
|
||||
ReasonBindingMismatch RefuseReason = "binding_mismatch" // signature/confirmation is for a different action/device
|
||||
|
||||
// Storage-wipe customer-confirmable tier (user-data only).
|
||||
ReasonCustomerConfirmed RefuseReason = "customer_confirmed" // allowed by the customer's durable-id-bound confirmation
|
||||
ReasonPendingConfirmation RefuseReason = "pending_confirmation" // user-data wipe awaiting the customer's confirmation
|
||||
)
|
||||
|
||||
// Decision is the gate verdict.
|
||||
@@ -94,6 +98,9 @@ type AuditRecord struct {
|
||||
Reason RefuseReason
|
||||
KeyID string // matched signer's key id, when signed
|
||||
Nonce string // the op nonce, when signed
|
||||
// DurableID is the device's durable id on a customer-confirmable storage wipe — the load-bearing
|
||||
// audit detail ("who/what/when + the durable id") so a customer-authorized wipe is attributable.
|
||||
DurableID string
|
||||
}
|
||||
|
||||
// Gate is the reversibility gate: it sits in front of the per-guest queue's executor
|
||||
@@ -172,6 +179,75 @@ func (g *Gate) Authorize(intent Intent, signed *SignedOp) Decision {
|
||||
return d
|
||||
}
|
||||
|
||||
// StorageWipeAuthz is the input to AuthorizeStorageWipe. Role is the agent's AUTHORITATIVE
|
||||
// classification (StorageRole* in classify.go) — never a caller's claim. DeviceDurableID is the
|
||||
// durable id the AGENT re-resolved for the device. Confirmed/ConfirmDurableID are the customer's
|
||||
// informed-confirmation, honored ONLY for user-data and ONLY when ConfirmDurableID matches the agent's
|
||||
// DeviceDurableID (a confirmation for one disk can't wipe another).
|
||||
type StorageWipeAuthz struct {
|
||||
HostID string
|
||||
Role string // StorageRoleSystem | StorageRoleBackup | StorageRoleUserData
|
||||
TargetID string // storage target identity for the operator-signed (system/backup) binding
|
||||
DeviceDurableID string // agent-re-resolved durable id of the device ("" if unresolvable)
|
||||
Confirmed bool
|
||||
ConfirmDurableID string
|
||||
}
|
||||
|
||||
// AuthorizeStorageWipe tiers a data-bearing storage wipe by the agent's role verdict:
|
||||
//
|
||||
// - user-data → CUSTOMER-CONFIRMABLE: allowed iff the request carries an explicit customer
|
||||
// confirmation BOUND to the device's durable id (the agent re-resolved DeviceDurableID and it
|
||||
// matches ConfirmDurableID). No operator signature. The wipe is recorded in the customer-visible
|
||||
// audit log with the durable id.
|
||||
// - system / backup / anything else → the standard DESTRUCTIVE path (operator-signature /
|
||||
// pending_signature). The Confirmed flag is DELIBERATELY IGNORED: no customer confirmation can
|
||||
// wipe the appliance's system storage or the backup safety-net. A compromised controller
|
||||
// asserting confirmed:true is refused here BY ROLE — role is the agent's, never the caller's.
|
||||
//
|
||||
// signed is the operator signature for the system/backup path (nil on the inline format path → always
|
||||
// pending_signature; the signed completion runs via the signed-jobs runner).
|
||||
func (g *Gate) AuthorizeStorageWipe(in StorageWipeAuthz, signed *SignedOp) Decision {
|
||||
if in.Role == StorageRoleUserData {
|
||||
if !in.Confirmed {
|
||||
d := Decision{Allowed: false, Disposition: CustomerConfirmable, Reason: ReasonPendingConfirmation}
|
||||
g.recordWipe(in, d)
|
||||
return d
|
||||
}
|
||||
// Durable-id binding: the confirmation must name THIS exact device (re-resolved by the agent).
|
||||
// An unresolved/empty id fails safe (refuse), and a mismatch can't be retargeted to another disk.
|
||||
if in.DeviceDurableID == "" || in.ConfirmDurableID == "" || in.ConfirmDurableID != in.DeviceDurableID {
|
||||
d := Decision{Allowed: false, Disposition: CustomerConfirmable, Reason: ReasonBindingMismatch}
|
||||
g.recordWipe(in, d)
|
||||
return d
|
||||
}
|
||||
d := Decision{Allowed: true, Disposition: CustomerConfirmable, Reason: ReasonCustomerConfirmed}
|
||||
g.recordWipe(in, d)
|
||||
return d
|
||||
}
|
||||
// system / backup / unknown → operator-signature path. Confirmed is NOT consulted.
|
||||
intent := IntentForStorageDestructive(ClassStorageWipe, in.HostID, in.TargetID, nil, SourceOneShotJob)
|
||||
return g.Authorize(intent, signed)
|
||||
}
|
||||
|
||||
// recordWipe audits a customer-confirmable storage-wipe decision (allowed or refused) with the
|
||||
// device's durable id — the customer-visible "who/what/when + durable id" record.
|
||||
func (g *Gate) recordWipe(in StorageWipeAuthz, d Decision) {
|
||||
g.audit.Record(AuditRecord{
|
||||
Time: time.Now().UTC(),
|
||||
Class: ClassStorageWipe,
|
||||
HostID: in.HostID,
|
||||
GuestID: in.TargetID,
|
||||
Source: SourceOneShotJob,
|
||||
Disposition: d.Disposition,
|
||||
Allowed: d.Allowed,
|
||||
Reason: d.Reason,
|
||||
DurableID: in.DeviceDurableID,
|
||||
})
|
||||
g.logger.Info("gate decision (storage wipe)",
|
||||
"role", in.Role, "disposition", d.Disposition, "allowed", d.Allowed,
|
||||
"reason", d.Reason, "durable_id", in.DeviceDurableID)
|
||||
}
|
||||
|
||||
// roleAuthorizes enforces the doc 04 §4 two-key role model: the cold recovery key
|
||||
// authorizes ONLY key-rotation re-pins; the operational key authorizes ordinary
|
||||
// destructive ops AND planned key-rotation.
|
||||
@@ -278,7 +354,7 @@ func (s SlogAudit) Record(rec AuditRecord) {
|
||||
s.Logger.Info("audit: gate decision",
|
||||
"class", rec.Class, "host", rec.HostID, "guest", rec.GuestID, "source", rec.Source,
|
||||
"disposition", rec.Disposition, "allowed", rec.Allowed, "reason", rec.Reason,
|
||||
"key_id", rec.KeyID, "nonce", auditNonce(rec.Nonce))
|
||||
"key_id", rec.KeyID, "nonce", auditNonce(rec.Nonce), "durable_id", rec.DurableID)
|
||||
}
|
||||
|
||||
// auditNonce shortens a nonce for the log (full nonce is high-cardinality; a prefix is
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package reconcile
|
||||
|
||||
import "testing"
|
||||
|
||||
// The storage-authorization redesign: a USER-DATA data-bearing wipe is customer-confirmable
|
||||
// (durable-id-bound, no operator signature); SYSTEM and BACKUP stay operator-signature ONLY, and a
|
||||
// `confirmed:true` claim on them is REFUSED by role (a compromised controller can't relabel a
|
||||
// protected device to walk the gate). These assert the gate's tiering — the non-hollow checks the
|
||||
// spec calls for: "the gate refuses a `confirmed` wipe on system/backup (assert no exec); a user-data
|
||||
// confirmed wipe binds to the durable id (a mismatched id is refused)".
|
||||
|
||||
const durA = "byid:wwn-0x5000c500a"
|
||||
const durB = "byuuid:1111-2222"
|
||||
|
||||
// user-data + confirmed + matching durable id → allowed (customer-confirmable), audited with the id.
|
||||
func TestStorageWipe_UserDataConfirmed_Allowed(t *testing.T) {
|
||||
aud := &captureAudit{}
|
||||
g := NewGate(nil, testHost, aud, nil) // NO verifier pinned — proves no operator signature is needed
|
||||
d := g.AuthorizeStorageWipe(StorageWipeAuthz{
|
||||
HostID: testHost, Role: StorageRoleUserData,
|
||||
DeviceDurableID: durA, Confirmed: true, ConfirmDurableID: durA,
|
||||
}, nil)
|
||||
if !d.Allowed || d.Disposition != CustomerConfirmable || d.Reason != ReasonCustomerConfirmed {
|
||||
t.Fatalf("user-data confirmed+matching: got allowed=%v disp=%s reason=%s", d.Allowed, d.Disposition, d.Reason)
|
||||
}
|
||||
if len(aud.recs) != 1 || !aud.recs[0].Allowed || aud.recs[0].DurableID != durA {
|
||||
t.Fatalf("customer-confirmed wipe must be audited with the durable id: %+v", aud.recs)
|
||||
}
|
||||
}
|
||||
|
||||
// user-data + confirmed but the confirmation binds to a DIFFERENT disk's durable id → refused
|
||||
// (binding_mismatch). A confirmation for one disk can't wipe another.
|
||||
func TestStorageWipe_UserDataDurableMismatch_Refused(t *testing.T) {
|
||||
g := NewGate(nil, testHost, &captureAudit{}, nil)
|
||||
d := g.AuthorizeStorageWipe(StorageWipeAuthz{
|
||||
HostID: testHost, Role: StorageRoleUserData,
|
||||
DeviceDurableID: durA, Confirmed: true, ConfirmDurableID: durB, // confirms B, device is A
|
||||
}, nil)
|
||||
if d.Allowed || d.Reason != ReasonBindingMismatch {
|
||||
t.Fatalf("durable-id mismatch: got allowed=%v reason=%s, want refused binding_mismatch", d.Allowed, d.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// user-data + an UNRESOLVABLE device durable id fails safe (refused) even when confirmed.
|
||||
func TestStorageWipe_UserDataNoDurable_Refused(t *testing.T) {
|
||||
g := NewGate(nil, testHost, &captureAudit{}, nil)
|
||||
d := g.AuthorizeStorageWipe(StorageWipeAuthz{
|
||||
HostID: testHost, Role: StorageRoleUserData,
|
||||
DeviceDurableID: "", Confirmed: true, ConfirmDurableID: "",
|
||||
}, nil)
|
||||
if d.Allowed {
|
||||
t.Fatal("a wipe with no resolvable durable id must not be allowed even when confirmed")
|
||||
}
|
||||
}
|
||||
|
||||
// user-data, NOT confirmed → pending_confirmation (ask the customer; not a signature).
|
||||
func TestStorageWipe_UserDataUnconfirmed_PendingConfirmation(t *testing.T) {
|
||||
g := NewGate(nil, testHost, &captureAudit{}, nil)
|
||||
d := g.AuthorizeStorageWipe(StorageWipeAuthz{
|
||||
HostID: testHost, Role: StorageRoleUserData, DeviceDurableID: durA,
|
||||
}, nil)
|
||||
if d.Allowed || d.Disposition != CustomerConfirmable || d.Reason != ReasonPendingConfirmation {
|
||||
t.Fatalf("user-data unconfirmed: got allowed=%v disp=%s reason=%s", d.Allowed, d.Disposition, d.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// THE HEADLINE: a `confirmed:true` wipe on a SYSTEM device is REFUSED — it falls to the
|
||||
// operator-signature path (no signer pinned → pending_signature), NOT customer-confirmable. The
|
||||
// customer's confirmation is ignored BY ROLE. Same for BACKUP.
|
||||
func TestStorageWipe_SystemConfirmedTrue_RefusedBySignature(t *testing.T) {
|
||||
for _, role := range []string{StorageRoleSystem, StorageRoleBackup} {
|
||||
g := NewGate(nil, testHost, &captureAudit{}, nil)
|
||||
d := g.AuthorizeStorageWipe(StorageWipeAuthz{
|
||||
HostID: testHost, Role: role,
|
||||
DeviceDurableID: durA, Confirmed: true, ConfirmDurableID: durA, // a matching confirmation — must NOT help
|
||||
}, nil)
|
||||
if d.Allowed {
|
||||
t.Fatalf("role=%s: a confirmed wipe of a protected device was ALLOWED — invariant violated", role)
|
||||
}
|
||||
if d.Disposition != Destructive || d.Reason != ReasonPendingSignature {
|
||||
t.Fatalf("role=%s: got disp=%s reason=%s, want destructive/pending_signature", role, d.Disposition, d.Reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An UNKNOWN/empty role fails safe to the protected (operator-signature) path, never user-data.
|
||||
func TestStorageWipe_UnknownRole_FailsSafeDestructive(t *testing.T) {
|
||||
g := NewGate(nil, testHost, &captureAudit{}, nil)
|
||||
d := g.AuthorizeStorageWipe(StorageWipeAuthz{
|
||||
HostID: testHost, Role: "", Confirmed: true, DeviceDurableID: durA, ConfirmDurableID: durA,
|
||||
}, nil)
|
||||
if d.Allowed || d.Disposition != Destructive {
|
||||
t.Fatalf("unknown role must fail safe to destructive: got allowed=%v disp=%s", d.Allowed, d.Disposition)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user