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
+37
View File
@@ -3,6 +3,43 @@
All notable changes to **felhom-agent** are recorded here. Update on every code All notable changes to **felhom-agent** are recorded here. Update on every code
change that gets pushed. change that gets pushed.
## v0.23.0 — device-ROLE classification + tiered storage-wipe gate (system/backup operator-only, user-data customer-confirmable) (2026-06-11)
The storage-authorization redesign (agent half). The gate's destructive-wipe path is now **tiered by
the device's protection ROLE**, which the agent classifies from its OWN inspection — never the
caller's claim (the storage analog of classify.go's data-bearing verdict).
- **`internal/storage/role.go`** — `DeviceRole` (`system` | `backup` | `user-data`) + the
authoritative classifier. `RoleForStorage` (storage-view targets) and `RoleForRawDevice` (a raw
device, e.g. a fresh disk in the init flow) map a device to its tier via `SystemDisks` (the
whole-disks backing `/`, `/boot`, `/boot/efi`, root-free reads). Rules: `pbs` → backup; `lvmthin` /
builtin `local` / nfs / cifs / unknown → system; `usb` / `local-dir` on a **non-system external
device** → user-data. **Fail-safe**: any ambiguity (system disks unknown, or an unrecognizable
device topology) → **system** (most-protected) — never silently user-data.
- **`GET /disks`** — each `DiskInfo` now carries `role`. The controller drives the UI from it
(system/backup get a lock + no destructive controls; user-data is customer-manageable).
- **Gate tier (`reconcile`)** — new `CustomerConfirmable` disposition + `Gate.AuthorizeStorageWipe`:
- role=**user-data** → **customer-confirmable**: allowed iff the request carries an explicit
customer confirmation **bound to the device's durable id** (the agent re-resolves the durable id
and matches; a confirmation for one disk can't wipe another). **No operator signature.** 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. Recorded in the **audit log** with the durable id
(`AuditRecord.DurableID`).
- role=**system**/**backup** → unchanged **operator-signature** (`pending_signature`). The
`confirmed` flag is **IGNORED** — a compromised controller asserting `confirmed:true` on a
protected device is refused **by role**. Every other destructive class (`guest_destroy`,
`decommission`, `restore_overwrite`, `key_rotation`) keeps operator-signature exactly as before.
- **`POST /disks/format`** — accepts `confirmed` + `durable_id` (inert for system/backup). The
data-bearing path tiers by role: user-data customer-confirmed → `mkfs`; user-data unconfirmed →
403 `needs_confirmation` (+ the durable id to confirm against, NOT an opsign command); system/backup
→ 403 with the operator-signature pending op (as before). Blank devices stay benign `mkfs`.
- **Tests** — `role_test.go` (demo-storage mapping + fail-safe), `storage_wipe_test.go` (the gate
refuses a `confirmed` wipe on system/backup → no exec; durable-id mismatch / missing-durable
refused; unknown role fails safe), and the localapi format-handler branches (user-data confirmed →
mkfs; user-data unconfirmed → needs_confirmation, no opsign; confirmed-but-protected → still refused).
Pairs with the controller's lockout + type-to-confirm UX + drive-list restyle.
## v0.22.0 — expose durable_id in GET /disks (enable controller-side guided storage) (2026-06-11) ## v0.22.0 — expose durable_id in GET /disks (enable controller-side guided storage) (2026-06-11)
One-line, read-only addition: `localapi.DiskInfo` gains `durable_id` (mapped from One-line, read-only addition: `localapi.DiskInfo` gains `durable_id` (mapped from
+58 -23
View File
@@ -1,30 +1,65 @@
# REPORT — agent v0.22.0: expose durable_id in GET /disks (2026-06-11) # REPORT — felhom-agent v0.23.0: device-ROLE classification + tiered storage-wipe gate
**Repo:** `felhom-agent` · **Version:** 0.22.0 · **Pushed commit:** `4734d4a` · paired with ## What this implements
`felhom-controller` v0.43.0 (the controller-side storage-management rebuild that needs this).
## What shipped (one read-only field) The **agent half** of the storage-authorization redesign (CC SPEC, Part A). Destructive storage
wipes are now **tiered by the device's protection ROLE**, classified by the agent from its own
inspection — never the controller's or hub's claim.
`internal/localapi/DiskInfo` gains `durable_id` (mapped directly from `StorageTarget.DurableID` — e.g. ### A1 — Device-ROLE classification (authoritative)
`"uuid:<fs-uuid>"` for usb/local-dir, `"path:…"`/`"store:…"` otherwise). That's the entire change. - New `internal/storage/role.go`: `DeviceRole` = `system` | `backup` | `user-data`.
- `SystemDisks(host)` resolves the OS whole-disks (the disks backing `/`, `/boot`, `/boot/efi`) from
the mount table + world-readable `/dev` symlinks (root-free; the root-CLI fence is untouched).
- `RoleForStorage(typ, backingDevice, …)` tiers a storage-view target; `RoleForRawDevice(device, …)`
tiers a raw device (a fresh disk in the init flow, not yet a PVE storage).
- `pbs`**backup**; `lvmthin` / builtin `local` / nfs / cifs / unknown → **system**;
`usb` / `local-dir` on a **non-system external device****user-data**.
- **Fail-safe**: any ambiguity → **system** (most-protected) — consistent with destructive-on-ambiguity.
- `GET /disks` → each `DiskInfo` now carries `role`.
**Why it's necessary:** the de-privileged controller cannot read a device's filesystem UUID itself, yet ### A2 — Gate tier (`internal/reconcile`)
`POST /disks/assign` mounts **strictly by fs UUID** (`EnsureMount``/dev/disk/by-uuid/<UUID>`). Without - New `CustomerConfirmable` disposition + `StorageRole{System,Backup,UserData}` (string mirror of
the UUID surfaced somewhere the controller can reach, the guided init/attach flows could not complete the `storage.DeviceRole`, kept in reconcile to avoid a storage→reconcile import edge).
mount. `GET /disks` already carried everything else (name/type/state/device/mount/class/data_bearing) but - `Gate.AuthorizeStorageWipe(StorageWipeAuthz, signed)`:
dropped the durable id. The controller strips the `uuid:` prefix to get the assign key. - **user-data** → allowed iff `Confirmed` AND the confirmation's durable id matches the device's
agent-re-resolved durable id (`ReasonCustomerConfirmed`); unconfirmed → `ReasonPendingConfirmation`;
mismatch / missing durable → `ReasonBindingMismatch`. Audited with the durable id.
- **system/backup/unknown** → the standard operator-signature path (`pending_signature`); `Confirmed`
is ignored — refused **by role**.
- `AuditRecord.DurableID` added (the customer-visible "who/what/when + durable id").
**Security:** no new privilege, no subprocess, no behaviour change to `format`/`assign`/`eject` or the ### A3 — Endpoint plumbing (`internal/localapi`)
data-bearing signature gate. The agent still inspects the device itself at format time and refuses a - `POST /disks/format` accepts `confirmed` + `durable_id`. The data-bearing path:
data-bearing wipe with a `pending_op` (the controller surfaces the `felhom-opsign` command). - resolves the device's **authoritative role** (`deviceRole` — prefers a matching storage target's
role so PBS is recognized as backup, else raw classification), re-resolves the durable id, and
calls the tiered gate.
- user-data confirmed → `mkfs` (200); user-data unconfirmed/mismatch → 403 with `needs_confirmation`
+ the durable id (no opsign); system/backup → 403 with the operator-signature pending op.
- blank devices stay benign `mkfs` (unchanged).
- `StorageGate` interface widened: `AuthorizeWipe(WipeRequest) WipeDecision`. Adapter in `main.go`
bridges to `Gate.AuthorizeStorageWipe`.
- Eject stays **benign** (unmount, data preserved) — its customer confirmation is a controller-side
UI concern, not a gate tier (per the hard rule: confirmation walks the gate ONLY for user-data
`storage_wipe`).
## Validation (live, guest 9201) ## Tests (non-hollow)
`GET /disks` (via the controller's `/api/disks` proxy) now returns `durable_id` per disk, e.g. - `internal/storage/role_test.go` — demo-storage mapping (`local`/`local-lvm`/`felhom-pbs` protected,
`felhom-usb` `/dev/sdb1`, `data_bearing:true`, `durable_id:uuid:277a2179-a764-4758-b840-9ea741517914`. `felhom-usb` on `/dev/sdb1` user-data) + fail-safe when system disks are unknown.
The controller's guided init on `sdb` correctly drove the agent to a **data-bearing refusal** (HTTP 403 → - `internal/reconcile/storage_wipe_test.go` — a `confirmed:true` wipe on **system/backup is refused**
`pending_op{op:storage_wipe, host_scope:demo-felhom-01, durable_id:byid:wwn-0x5000039ddb108568-part1}`), (falls to pending_signature, no exec); user-data durable-id **mismatch refused**; missing durable
which the controller surfaced as the operator `felhom-opsign` command — the gate held end-to-end. refused; unconfirmed → pending_confirmation; unknown role fails safe.
- `internal/localapi/disks_test.go` — format-handler branches: user-data confirmed → mkfs; user-data
unconfirmed → 403 needs_confirmation with **no** operator-signature pending op;
confirmed-but-protected → still refused (role beats confirmation); blank → mkfs without the gate.
## Notes `go build ./... && go vet ./... && go test ./...` all green (Go 1.26, local).
This pairs with the controller's storage-management rebuild (build-golden default bumped to controller
0.43.0; golden rebaked). No other agent change. ## Version
`v0.22.0 → v0.23.0` (`version` var in `cmd/felhom-agent/main.go`).
## Notes / residuals
- The one accepted residual (per spec): a compromised controller could `mkfs` a **user-data** drive
it could already `rm -rf` — same data loss, no new reach. System/backup remain operator-only.
- LVM-root caveat: when `/` is on device-mapper, the OS whole-disk is pinned via `/boot`/`/boot/efi`
(a raw ESP partition on the OS disk on standard Proxmox/Debian installs); the fail-safe default
covers anything that cannot be resolved.
+23 -11
View File
@@ -43,7 +43,7 @@ import (
// version is the agent version. Overridable at build time with // version is the agent version. Overridable at build time with
// -ldflags "-X main.version=<v>"; defaults to the in-repo CHANGELOG version. // -ldflags "-X main.version=<v>"; defaults to the in-repo CHANGELOG version.
var version = "0.22.0" var version = "0.23.0"
func main() { func main() {
var ( var (
@@ -580,21 +580,33 @@ func buildLocalAPIServer(cfg config.Config, px *proxmox.Client, store *backup.St
return srv return srv
} }
// storageGateAdapter bridges the local-API disk-format path to the slice-4 reversibility gate // storageGateAdapter bridges the local-API disk-format path to the reversibility gate, TIERED by the
// (8C): a data-bearing format is a ClassStorageWipe destructive op, so with no operator signature // agent's authoritative device-role verdict: a USER-DATA data-bearing wipe is customer-confirmable
// the gate returns pending_signature and the agent refuses. The signed completion is slice 10. // (durable-id-bound, no signature); a SYSTEM/BACKUP wipe stays operator-signature (unsigned →
// pending_signature; the signed completion runs via the signed-jobs runner).
type storageGateAdapter struct { type storageGateAdapter struct {
gate *reconcile.Gate gate *reconcile.Gate
hostID string hostID string
} }
func (a storageGateAdapter) AuthorizeWipe(device string) (bool, string) { func (a storageGateAdapter) AuthorizeWipe(req localapi.WipeRequest) localapi.WipeDecision {
params, _ := json.Marshal(map[string]string{"device": device, "op": "format"}) dec := a.gate.AuthorizeStorageWipe(reconcile.StorageWipeAuthz{
dec := a.gate.Authorize( HostID: a.hostID,
reconcile.IntentForStorageDestructive(reconcile.ClassStorageWipe, a.hostID, device, params, reconcile.SourceOneShotJob), Role: req.Role,
nil, // no operator signature in 8C → pending_signature DeviceDurableID: req.DeviceDurableID,
) Confirmed: req.Confirmed,
return dec.Allowed, string(dec.Reason) ConfirmDurableID: req.ConfirmDurableID,
}, nil) // no operator signature on the inline path → system/backup is pending_signature
tier := "destructive"
if dec.Disposition == reconcile.CustomerConfirmable {
tier = "customer_confirmable"
}
return localapi.WipeDecision{
Allowed: dec.Allowed,
Tier: tier,
Reason: string(dec.Reason),
NeedsConfirmation: dec.Reason == reconcile.ReasonPendingConfirmation,
}
} }
// reconcileJournalPath chooses the op-journal path: a `journal.log` sibling of the // reconcileJournalPath chooses the op-journal path: a `journal.log` sibling of the
+125 -36
View File
@@ -27,10 +27,31 @@ type DiskOps interface {
} }
// StorageGate authorizes a DESTRUCTIVE storage op (a data-bearing wipe/format) through the // 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 // reversibility gate, TIERED by the agent's authoritative device-role verdict. Satisfied by an
// unsigned destructive op returns (false, "pending_signature"); the signed path is slice 10. // 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 { 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 // 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) BackingDevice string `json:"backing_device"` // /dev/sdb1, … ("" for network/lvm)
MountPath string `json:"mount_path"` MountPath string `json:"mount_path"`
Class string `json:"class"` // fast | slow | "" 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) DataBearing bool `json:"data_bearing"` // agent device-inspection verdict (UI hint)
DataReason string `json:"data_reason,omitempty"` DataReason string `json:"data_reason,omitempty"`
// DurableID is the target's stable identity (e.g. "uuid:<fs-uuid>" for usb/local-dir). The // 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") writeErr(w, http.StatusBadGateway, "could not read storage view")
return 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)) out := make([]DiskInfo, 0, len(targets))
for _, t := range targets { for _, t := range targets {
di := DiskInfo{ di := DiskInfo{
Name: t.Name, Type: t.Type, State: t.State, Name: t.Name, Type: t.Type, State: t.State,
BackingDevice: t.BackingDevice, MountPath: t.MountPath, Class: t.ClassHint, BackingDevice: t.BackingDevice, MountPath: t.MountPath, Class: t.ClassHint,
DurableID: t.DurableID, 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 // Inspect the backing device for the UI's data-bearing hint (the authoritative check
// is re-run at format time on the actual device). // is re-run at format time on the actual device).
@@ -159,7 +189,14 @@ type formatRequest struct {
VMID int `json:"vmid"` VMID int `json:"vmid"`
Device string `json:"device"` Device string `json:"device"`
FSType string `json:"fstype"` 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). // inspects the device itself (8C invariant).
} }
@@ -174,7 +211,14 @@ type FormatResponse struct {
Formatted bool `json:"formatted"` Formatted bool `json:"formatted"`
DataBearing bool `json:"data_bearing"` DataBearing bool `json:"data_bearing"`
Reason string `json:"reason"` 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"` 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) 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) // inspect error → fail-safe data-bearing (probe.DataBearing() is true on !Probed)
} }
if probe.DataBearing() { if !probe.DataBearing() {
// Destructive: route through the gate. With no operator signature → pending_signature. // Blank device → benign → mkfs (role is irrelevant; there is nothing to destroy).
allowed, reason := s.diskGate.AuthorizeWipe(req.Device) if err := s.disks.Format(r.Context(), req.Device, req.FSType); err != nil {
if !allowed { s.logger.Error("local-api: format", "vmid", vmid, "device", req.Device, "err", err)
// Surface the bound op the operator must sign (slice 10B): derive the DURABLE device id writeErr(w, http.StatusBadGateway, "format failed: "+err.Error())
// 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+")")
return return
} }
// A signed wipe is executed by the signed-jobs runner (queue → verify gate → durable writeOK(w, FormatResponse{VMID: vmid, Device: req.Device, Formatted: true, DataBearing: false, Reason: "blank device formatted " + req.FSType})
// 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)")
return return
} }
// Blank device → benign → mkfs. // Data-bearing → TIER by the agent's authoritative role classification (its own inspection,
if err := s.disks.Format(r.Context(), req.Device, req.FSType); err != nil { // never the caller's claim). The agent also re-resolves the device's durable id; the customer's
s.logger.Error("local-api: format", "vmid", vmid, "device", req.Device, "err", err) // confirmation must bind to it.
writeErr(w, http.StatusBadGateway, "format failed: "+err.Error()) 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 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 // 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...) } func (f *fakeDiskOps) formatted() []string { f.mu.Lock(); defer f.mu.Unlock(); return append([]string(nil), f.formatCalls...) }
type fakeGate struct { type fakeGate struct {
allowed bool mu sync.Mutex
reason string decision WipeDecision
calls []string reqs []WipeRequest
} }
func (g *fakeGate) AuthorizeWipe(device string) (bool, string) { func (g *fakeGate) AuthorizeWipe(req WipeRequest) WipeDecision {
g.calls = append(g.calls, device) g.mu.Lock()
return g.allowed, g.reason 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 } 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. // A blank device (the agent's own probe says blank) is formatted — mkfs called, gate NOT consulted.
func TestFormat_BlankDevice_Formats(t *testing.T) { func TestFormat_BlankDevice_Formats(t *testing.T) {
d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true}} // blank: Probed, nothing set 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) h := newDiskServer(t, d, g, nil, nil)
w := do(t, h, "POST", "/disks/format", "A", `{"device":"/dev/sdb","fstype":"ext4"}`) 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" { if got := d.formatted(); len(got) != 1 || got[0] != "/dev/sdb" {
t.Fatalf("mkfs not called for blank device: %v", got) 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)") 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 // THE HEADLINE SECURITY TEST: a caller asks to format a DATA-BEARING device the gate tiers as
// device itself, classifies it destructive, the gate refuses pending_signature, and **mkfs is // SYSTEM/BACKUP (destructive). The gate refuses pending_signature, **mkfs is NEVER called**, and the
// NEVER called** — the caller's intent cannot wipe data-bearing storage. // response surfaces the operator-signature pending op (NOT a customer-confirmation prompt).
func TestFormat_DataBearingDevice_RefusedNoMkfs(t *testing.T) { func TestFormat_DataBearing_ProtectedRole_RefusedNoMkfs(t *testing.T) {
d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true, HasFilesystem: true, FSType: "ext4"}} 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) h := newDiskServer(t, d, g, nil, nil)
w := do(t, h, "POST", "/disks/format", "A", `{"device":"/dev/sdb","fstype":"ext4"}`) w := do(t, h, "POST", "/disks/format", "A", `{"device":"/dev/sdb","fstype":"ext4"}`)
if w.Code != http.StatusForbidden { 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 { 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" { if len(g.requests()) != 1 {
t.Fatalf("gate not consulted for the destructive format: %v", g.calls) t.Fatalf("gate not consulted for the destructive format: %v", g.requests())
} }
if !strings.Contains(w.Body.String(), "operator signature") { if !strings.Contains(w.Body.String(), "operator signature") {
t.Fatalf("response did not signal an operator signature is needed: %s", w.Body.String()) 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 // 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) { func TestFormat_AmbiguousProbe_TreatedDestructive(t *testing.T) {
d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: false}} // probe failed → DataBearing()=true 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) h := newDiskServer(t, d, g, nil, nil)
w := do(t, h, "POST", "/disks/format", "A", `{"device":"/dev/sdb","fstype":"ext4"}`) 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) { func TestFormat_RejectsBadDeviceOrFSType(t *testing.T) {
d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true}} d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true}}
h := newDiskServer(t, d, &fakeGate{}, nil, nil) h := newDiskServer(t, d, &fakeGate{}, nil, nil)
+17
View File
@@ -49,6 +49,23 @@ const (
Benign Disposition = "benign" Benign Disposition = "benign"
// Destructive — an operator signature bound to the action is REQUIRED. // Destructive — an operator signature bound to the action is REQUIRED.
Destructive Disposition = "destructive" 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 // Provenance is AGENT-INTERNAL evidence that an otherwise-destructive action is
+78 -2
View File
@@ -53,7 +53,11 @@ const (
ReasonPendingSignature RefuseReason = "pending_signature" // destructive, no/again-needed signature ReasonPendingSignature RefuseReason = "pending_signature" // destructive, no/again-needed signature
ReasonRejected RefuseReason = "rejected" // signature failed authz verification ReasonRejected RefuseReason = "rejected" // signature failed authz verification
ReasonRoleDenied RefuseReason = "role_denied" // signer role not authorized for this op class 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. // Decision is the gate verdict.
@@ -94,6 +98,9 @@ type AuditRecord struct {
Reason RefuseReason Reason RefuseReason
KeyID string // matched signer's key id, when signed KeyID string // matched signer's key id, when signed
Nonce string // the op nonce, 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 // 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 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 // 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 // authorizes ONLY key-rotation re-pins; the operational key authorizes ordinary
// destructive ops AND planned key-rotation. // destructive ops AND planned key-rotation.
@@ -278,7 +354,7 @@ func (s SlogAudit) Record(rec AuditRecord) {
s.Logger.Info("audit: gate decision", s.Logger.Info("audit: gate decision",
"class", rec.Class, "host", rec.HostID, "guest", rec.GuestID, "source", rec.Source, "class", rec.Class, "host", rec.HostID, "guest", rec.GuestID, "source", rec.Source,
"disposition", rec.Disposition, "allowed", rec.Allowed, "reason", rec.Reason, "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 // auditNonce shortens a nonce for the log (full nonce is high-cardinality; a prefix is
+95
View File
@@ -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)
}
}
+138
View File
@@ -0,0 +1,138 @@
package storage
import (
"path/filepath"
"regexp"
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
)
// Device ROLE classification — the protection tier the AGENT assigns a storage/device by its OWN
// inspection (the PVE storage view + the host mount/topology reads it already gathers), NEVER from
// the controller's or the hub's claim. This is the storage analog of classify.go's data-bearing
// verdict: "provenance is agent-internal, never populated from an external input, else a compromised
// hub could relabel a protected device to walk the gate."
//
// The tier decides who may authorize a destructive wipe of the device:
// - system : the appliance's OS/boot/EFI/guest-rootfs storage. Operator-signature ONLY.
// - backup : the backup safety-net (PBS). Operator-signature ONLY (wiping it destroys the net).
// - user-data : a customer external data drive — already within the controller's blast radius
// (it bind-mounts /mnt), so a customer informed-confirmation may authorize a wipe.
//
// On ANY ambiguity the agent defaults to the MOST-PROTECTED role (system) — consistent with the
// destructive-on-ambiguity invariant: an unrecognized device is treated as protected, never silently
// user-data.
type DeviceRole string
const (
RoleSystem DeviceRole = "system"
RoleBackup DeviceRole = "backup"
RoleUserData DeviceRole = "user-data"
)
// reWholeDisk matches a whole raw disk path (no partition suffix): /dev/sda, /dev/vdb, /dev/nvme0n1.
var reWholeDisk = regexp.MustCompile(`^/dev/(?:sd|hd|vd)[a-z]+$|^/dev/nvme[0-9]+n[0-9]+$`)
// systemMountPoints are the host mountpoints whose backing whole-disk is, by definition, the OS /
// system disk. /boot and /boot/efi are the load-bearing ones: on a typical Proxmox/Debian install
// the ESP is a raw partition directly on the OS disk, so it pins the OS whole-disk even when / is on
// LVM/device-mapper (which we cannot trace back to a raw disk without privileged LVM introspection).
var systemMountPoints = map[string]bool{"/": true, "/boot": true, "/boot/efi": true}
// SystemDisks resolves the set of whole-disk device paths that host the OS (the disks backing /,
// /boot and /boot/efi). ok=false when NONE could be resolved (no system mountpoint mapped to a raw
// disk) — callers then treat every candidate as system (most protected). Root-free: it parses the
// mount table + world-readable /dev symlinks only (the root-CLI fence is untouched).
func SystemDisks(host HostReader) (set map[string]bool, ok bool) {
if host == nil {
return nil, false
}
mounts, err := host.Mounts()
if err != nil {
return nil, false
}
set = map[string]bool{}
for _, m := range mounts {
if !systemMountPoints[cleanMountPath(m.MountPoint)] {
continue
}
if wd, wok := wholeDiskOf(m.Device); wok {
set[wd] = true
}
}
return set, len(set) > 0
}
// wholeDiskOf maps a device path (a partition, a whole disk, or a /dev/disk/by-* symlink) to its
// whole-disk /dev path. ok=false when the result is not a recognizable raw disk (device-mapper / LVM
// / network) — the caller then treats the topology as undeterminable (→ most-protected).
func wholeDiskOf(device string) (string, bool) {
if device == "" {
return "", false
}
dev := device
if resolved, err := filepath.EvalSymlinks(device); err == nil {
dev = resolved // canonicalize /dev/disk/by-uuid/… → /dev/sdXN
}
if m := reNVMePart.FindStringSubmatch(dev); m != nil {
return m[1], true // /dev/nvme0n1p2 → /dev/nvme0n1
}
if m := reSDPart.FindStringSubmatch(dev); m != nil {
return m[1], true // /dev/sdb1 → /dev/sdb
}
if reWholeDisk.MatchString(dev) {
return dev, true // already a whole disk
}
return "", false // /dev/mapper/*, network, unrecognized → undeterminable
}
// isSystemBacked reports whether device's whole-disk is an OS/system disk. It FAILS SAFE: any
// ambiguity (system set unknown, or the device's whole-disk unrecognizable) returns true (system).
func isSystemBacked(device string, sysDisks map[string]bool, sysKnown bool) bool {
if !sysKnown {
return true // can't determine the system disks → treat as system (most protected)
}
wd, ok := wholeDiskOf(device)
if !ok {
return true // unrecognizable device topology → most protected
}
return sysDisks[wd]
}
// RoleForStorage classifies a storage TARGET (from the agent's storage view) into its protection
// tier. typ is the reported storage type; backingDevice is its resolved block device ("" for
// network/lvm/dir-on-root). sysDisks/sysKnown come from SystemDisks (resolved once per request).
func RoleForStorage(typ, backingDevice string, sysDisks map[string]bool, sysKnown bool) DeviceRole {
switch typ {
case hub.StorageTypePBS:
return RoleBackup // the backup safety-net — protected
case hub.StorageTypeUSB, hub.StorageTypeLocalDir:
// A removable/extra dir storage is user-data ONLY when it has its OWN block device that is
// NOT part of the system disk. No device (a dir on the root fs) or a system-disk-backed dir
// → system. This is exactly "local-dir/usb on a non-root external device → user-data".
if backingDevice == "" {
return RoleSystem
}
if isSystemBacked(backingDevice, sysDisks, sysKnown) {
return RoleSystem
}
return RoleUserData
default:
// local (builtin, on the root fs), lvmthin (local-lvm), lvm (thick), nfs, cifs, and anything
// unrecognized → protected. Network shares are NOT customer-managed external drives in this
// model and are not within the controller's blast radius, so protecting them is safe.
return RoleSystem
}
}
// RoleForRawDevice classifies a RAW block device (the format endpoint's target, which may not yet be
// a registered PVE storage — e.g. a fresh external disk in the init flow). It distinguishes system
// from user-data by system-disk membership. A raw /dev path is never a PBS datastore (those are
// network/API), so the backup tier is not reachable here — the storage-list view (RoleForStorage)
// tiers PBS. Defaults to system on ambiguity.
func RoleForRawDevice(device string, sysDisks map[string]bool, sysKnown bool) DeviceRole {
if isSystemBacked(device, sysDisks, sysKnown) {
return RoleSystem
}
return RoleUserData
}
+95
View File
@@ -0,0 +1,95 @@
package storage
import (
"testing"
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
)
// Role classification is AGENT-AUTHORITATIVE (the agent's own storage view + host topology). These
// assert the demo storages map to the right protection tier, and that ambiguity fails safe to the
// MOST-PROTECTED role (system) — never silently user-data.
// demoHost models the demo N100: the OS disk /dev/sda (ESP at /dev/sda1, root at /dev/sda2) plus an
// external data disk /dev/sdb (felhom-usb at /dev/sdb1).
func demoHost() *fakeHostReader {
return &fakeHostReader{
mounts: []Mount{
{Device: "/dev/sda2", MountPoint: "/", FSType: "ext4"},
{Device: "/dev/sda1", MountPoint: "/boot/efi", FSType: "vfat"},
{Device: "/dev/sdb1", MountPoint: "/mnt/hdd_1", FSType: "ext4"},
},
}
}
func TestSystemDisks_FromBootMounts(t *testing.T) {
sys, ok := SystemDisks(demoHost())
if !ok {
t.Fatal("SystemDisks should resolve the OS disk from / and /boot/efi")
}
if !sys["/dev/sda"] {
t.Fatalf("OS whole-disk /dev/sda not in system set: %v", sys)
}
if sys["/dev/sdb"] {
t.Fatalf("external data disk /dev/sdb wrongly classified as system: %v", sys)
}
}
func TestRoleForStorage_DemoMapping(t *testing.T) {
sys, ok := SystemDisks(demoHost())
cases := []struct {
name string
typ string
device string
want DeviceRole
}{
{"builtin local (root fs)", hub.StorageTypeLocal, "", RoleSystem},
{"local-lvm (lvmthin)", hub.StorageTypeLVMThin, "", RoleSystem},
{"felhom-pbs (backup net)", hub.StorageTypePBS, "", RoleBackup},
{"nfs share", hub.StorageTypeNFS, "", RoleSystem},
{"felhom-usb on external /dev/sdb1", hub.StorageTypeUSB, "/dev/sdb1", RoleUserData},
{"local-dir on external /dev/sdb1", hub.StorageTypeLocalDir, "/dev/sdb1", RoleUserData},
{"usb-typed but ON the system disk", hub.StorageTypeUSB, "/dev/sda2", RoleSystem},
{"local-dir with no device (on root)", hub.StorageTypeLocalDir, "", RoleSystem},
}
for _, c := range cases {
if got := RoleForStorage(c.typ, c.device, sys, ok); got != c.want {
t.Errorf("%s: got role %q, want %q", c.name, got, c.want)
}
}
}
func TestRoleForRawDevice_SystemVsUserData(t *testing.T) {
sys, ok := SystemDisks(demoHost())
if got := RoleForRawDevice("/dev/sdb1", sys, ok); got != RoleUserData {
t.Errorf("external /dev/sdb1: got %q, want user-data", got)
}
if got := RoleForRawDevice("/dev/sda2", sys, ok); got != RoleSystem {
t.Errorf("system /dev/sda2: got %q, want system", got)
}
// A partition on the OS disk is treated as system (protected), not user-data.
if got := RoleForRawDevice("/dev/sda3", sys, ok); got != RoleSystem {
t.Errorf("OS-disk partition /dev/sda3: got %q, want system", got)
}
}
// Ambiguity fails safe: if the system disks cannot be determined, EVERY candidate is system.
func TestRole_FailsSafeWhenSystemUnknown(t *testing.T) {
noMounts := &fakeHostReader{mountsErr: errFake}
sys, ok := SystemDisks(noMounts)
if ok {
t.Fatal("SystemDisks should report not-ok when mounts cannot be read")
}
if got := RoleForRawDevice("/dev/sdb1", sys, ok); got != RoleSystem {
t.Errorf("unknown system disks → external device must default to system, got %q", got)
}
if got := RoleForStorage(hub.StorageTypeUSB, "/dev/sdb1", sys, ok); got != RoleSystem {
t.Errorf("unknown system disks → usb target must default to system, got %q", got)
}
}
var errFake = &fakeErr{}
type fakeErr struct{}
func (*fakeErr) Error() string { return "fake" }