From d1bd44d2d538f98c92335a7f6a7ec7082adca6f2 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Fri, 12 Jun 2026 09:50:28 +0200 Subject: [PATCH] docs(v0.24.0): REPORT for the eject role-gate; live-validated on guest 9201 Co-Authored-By: Claude Opus 4.8 (1M context) --- REPORT.md | 100 +++++++++++++++++++++++------------------------------- 1 file changed, 42 insertions(+), 58 deletions(-) diff --git a/REPORT.md b/REPORT.md index 1b6eed9..3cbf49c 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,65 +1,49 @@ -# REPORT — felhom-agent v0.23.0: device-ROLE classification + tiered storage-wipe gate +# REPORT — felhom-agent v0.24.0: role-gate the eject path -## What this implements +## What changed (Part A of the storage-fixes spec) -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. +`POST /disks/eject` is now **role-gated at the agent**. Previously `handleDiskEject` validated scope + +`where`, computed dependents, then unconditionally called `Unmount` — so a direct API call (or a +compromised controller) could `POST /disks/eject {where:"/var/lib/vz"}` (or the PBS mount) and unmount +a protected storage. The controller UI hid the button for system/backup, but the **agent did not +enforce it**, contradicting the "system is never unmountable" invariant and the defense-in-depth the +wipe path upholds. -### A1 — Device-ROLE classification (authoritative) -- 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`. +### A1 — refuse eject of system/backup (`internal/localapi/disks.go`) +- `handleDiskEject` now, before `Unmount`, resolves the **authoritative protection role** of the + storage mounted at `where` via the new `roleForMountPath` (the agent's own storage-view + + host-topology classification — `RoleForStorage` keyed on `MountPath == where` — never the caller's + claim). It refuses **403 with no `Unmount`** unless the role is `user-data`. +- **Fail-safe**: a view error or no storage target found at `where` → treated as `system` (most- + protected) → refused. Consistent with the wipe gate's most-protected-on-ambiguity default. +- Mirrors the wipe path's logging: `"protected — eject refused by role"`. +- `hostReader()` seam added (`Options.HostReader`, optional; defaults to the production + `*storage.ProcHostReader`) so the role classification (`SystemDisks`) is unit-testable. `handleDisks` + and `deviceRole` now share the same seam (no production behaviour change — default is the real reader). -### A2 — Gate tier (`internal/reconcile`) -- New `CustomerConfirmable` disposition + `StorageRole{System,Backup,UserData}` (string mirror of - `storage.DeviceRole`, kept in reconcile to avoid a storage→reconcile import edge). -- `Gate.AuthorizeStorageWipe(StorageWipeAuthz, signed)`: - - **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"). +### A2 — non-hollow test +- `TestEject_RoleGated` asserts a `system` mount and a `backup` (PBS) mount are **refused with no + `Unmount`**, a `user-data` mount **ejects** (Unmount called once), and an unresolvable mount **fails + safe to refused** — the same non-hollowness (executor-not-invoked) the wipe tests use. +- `TestEject_UnmountAndDependents` updated to a user-data target so the role-gate permits it. -### A3 — Endpoint plumbing (`internal/localapi`) -- `POST /disks/format` accepts `confirmed` + `durable_id`. The data-bearing path: - - 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`). +## Build / deploy +- Version bumped `0.23.0 → 0.24.0` (`cmd/felhom-agent/main.go`). CHANGELOG updated (newest on top). +- Built on the build server (go1.26) with `-ldflags -X main.version=0.24.0`, installed to + `/usr/local/bin/felhom-agent` on `felhom-pve` (prior binary kept as `.bak-0.23.0`), service + restarted — `felhom-agent --version` → `0.24.0`, local-api listening on `192.168.0.162:8443`. -## Tests (non-hollow) -- `internal/storage/role_test.go` — demo-storage mapping (`local`/`local-lvm`/`felhom-pbs` protected, - `felhom-usb` on `/dev/sdb1` user-data) + fail-safe when system disks are unknown. -- `internal/reconcile/storage_wipe_test.go` — a `confirmed:true` wipe on **system/backup is refused** - (falls to pending_signature, no exec); user-data durable-id **mismatch refused**; missing durable - 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. +## Live validation (felhom-pve, guest 9201) +- `GET /disks` (guest 9201): roles `felhom-usb=user-data`, `local=system`, `felhom-pbs=backup`, + `local-lvm=system`. +- Hand-issued `POST /disks/eject {where:"/var/lib/vz"}` (the `local` system storage's real mount) → + **HTTP 403**, body `mount is system/backup-protected — eject refused (role: system)`; agent logged + `protected — eject refused by role … role=system`; **no unmount** occurred. +- `POST /disks/eject {where:"/mnt/nonexistent"}` (no storage target) → **HTTP 403** (fail-safe). +- `/mnt/felhom-usb` (the live user-data data drive) was deliberately **not** live-ejected to avoid + disrupting the running guest; the positive user-data eject path is covered by the non-hollow + `TestEject_RoleGated` (asserts `Unmount` is called exactly once for a user-data mount). +- `go test ./...` green for the whole module. -`go build ./... && go vet ./... && go test ./...` all green (Go 1.26, local). - -## 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. +## Notes / not done +- No golden change required for Part A — the agent runs host-side, not baked into guests.