docs: SPIKE pool-scoped ACL — empirical validation (PASS, findings for 3b)

Throwaway-token spike on felhom-pve: the agent's 16 privs scope from / to
/pool/felhom + /storage/<targets> (+Pool.Allocate) without breaking the flow.
CRUX proven: restore a fresh vmid INTO the pool works at /pool/felhom (T1 200).
T1-T7 all pass; blast radius contained (out-of-pool guests 403). Findings:
3-role layout (per-path subsets need separate roles); Datastore.Audit filters
so needn't be box-wide; Store scope MUST include felhom-pbs (offsite default);
restore-test scratch band (9990xx) not in pool → 3b must handle. All spike
objects cleaned up; live FelhomAgent/9201 untouched. No product code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-01 12:10:41 +02:00
parent b0e6d72bf8
commit 554845b44b
@@ -0,0 +1,177 @@
# SPIKE — pool-scoped ACL for the FelhomAgent token (2026-07-01)
**Class:** SPIKE (empirical validation; no product code). **Repos read:** felhom-agent `33e3443` (v0.52.0,
read-only). **Host:** felhom-pve (192.168.0.162, node `demo-felhom`, PVE 9.2.2, **single-node**).
**Verdict: PASS** — the agent's 16 privileges CAN be scoped from `/` to `/pool/felhom` +
`/storage/<targets>` (+`Pool.Allocate`) without breaking the provision/backup/guest flow, and the scoped
token is provably contained to the pool. One caveat for 3b: the Datastore scope must include **all**
agent-touched storages (notably the offsite `felhom-pbs`), not just `local`/`local-lvm`.
The live `FelhomAgent` role / `felhom-agent@pve` token / its `/` ACL / guest 9201 were **never touched**;
all testing used a throwaway `felhom-spike@pve!spike` token + granular `FelhomSpike*` roles + scratch
vmid 9300, all removed at the end. Live agent verified healthy afterwards.
---
## 1. Confirmed real storage names (felhom-pve)
From the live agent config + `pvesm`:
| Role in the flow | Storage | Notes |
|---|---|---|
| **archive** (golden read source) | `local` | golden = `local:backup/vzdump-lxc-9100-2026_06_27-11_42_51.tar.zst` |
| **restore** (rootfs write target) | `local-lvm` | `backup.restore_storage` |
| **dump** (local backup write) | `local` | `backup.local_backup_target` |
| **offsite dump** (default DR target) | `felhom-pbs` | PBS, 2009/9239 GiB used — actively used; **agent writes here** |
| enrolled drives | `felhom-usb`, `felhom-flash` | dir storages; agent enumerates (Audit); not vzdump targets today |
**Finding:** archive and local-dump are the SAME storage (`local`). The full set of storages the agent
touches is **5**, not 2 — see §7 for the 3b implication.
## 2. Privilege-use map — empirical column (object path PROVEN sufficient)
All ops run **as the spike token** (`Authorization: PVEAPIToken=felhom-spike@pve!spike=…`) against
`https://127.0.0.1:8006/api2/json`. "Proven-sufficient path" = where the grant actually satisfied PVE.
| Agent op | Endpoint | Priv | Proven-sufficient object | Test |
|---|---|---|---|---|
| RestoreLXC (**+pool**) | POST `/nodes/{n}/lxc` restore=1 pool=felhom | VM.Allocate + Pool.Allocate | **`/pool/felhom`** | T1 ✅ 200 |
| " (disks) | " | Datastore.AllocateSpace | `/storage/local-lvm` | T1 ✅ |
| " (archive read) | " | Datastore.Audit/read | `/storage/local` | T1 ✅ |
| task status/log | GET `/nodes/{n}/tasks/{upid}/{status,log}` | Sys.Audit | **`/`** | T2 ✅ 200 (`stopped OK`) |
| SetConfig | PUT `…/config` | VM.Config.{Disk,CPU,Memory,Network,Options} | `/pool/felhom` | T3 ✅ |
| ResizeLXC | PUT `…/resize` | VM.Config.Disk | `/pool/felhom` | T3 ✅ |
| Snapshot / Delete | POST/DELETE `…/snapshot[/{s}]` | VM.Snapshot | `/pool/felhom` | T3 ✅ |
| RollbackSnapshot | POST `…/snapshot/{s}/rollback` | VM.Snapshot.Rollback | `/pool/felhom` | T3 ✅ |
| Start / Stop | POST `…/status/{start,stop}` | VM.PowerMgmt | `/pool/felhom` | T3 ✅ (stop 200 once running) |
| guest reads | GET `…/status/current`,`…/config`,`…/snapshot` | VM.Audit | `/pool/felhom` | T3 ✅ |
| Vzdump | POST `/nodes/{n}/vzdump` | VM.Backup + Datastore.AllocateSpace | guest `/pool/felhom` + `/storage/local` | T4 ✅ (`OK`) |
| DestroyLXC | DELETE `/nodes/{n}/lxc/{id}` purge=1 | VM.Allocate + Datastore.Allocate | `/pool/felhom` + `/storage/*` | T6 ✅ (`OK`) |
| Version | GET `/version` | (auth only) | — | T5 ✅ |
| ListNodes / NodeStatus | GET `/nodes`,`/nodes/{n}/status` | Sys.Audit | `/` | T5 ✅ |
| ListLXC | GET `/nodes/{n}/lxc` | VM.Audit | `/pool/felhom` (returns **filtered** list) | T5 ✅ (only pool guests) |
| ListStorage / NodeStorage | GET `/storage`,`/nodes/{n}/storage` | Datastore.Audit | `/storage/<s>` (returns **filtered** list) | T5 ✅ (**no box-wide grant needed**) |
| StorageContent | GET `/nodes/{n}/storage/{s}/content` | Datastore.Audit | `/storage/<s>` | T5 ✅ |
## 3. CRUX verdict (T1) — create-into-pool WORKS at `/pool/felhom`
`POST /nodes/demo-felhom/lxc` with `restore=1, vmid=9300 (brand-new), ostemplate=<golden>,
storage=local-lvm, pool=felhom` **→ HTTP 200** + `UPID:…:vzrestore:9300:felhom-spike@pve!spike:`.
A fresh, not-yet-existing vmid CAN be allocated **into** the pool with `VM.Allocate` + `Pool.Allocate`
granted only at `/pool/felhom`**no** `VM.Allocate` at `/vms` or `/` was needed. This is the load-bearing
result: the intended design works, no fallback (the §3 fallbacks a/b/c were not needed). The restore then
ran to `stopped OK` and the guest was a pool member (visible only to the scoped token).
## 4. Recommended final ACL layout for 3b-impl
**Mechanism finding:** `pveum acl modify <path> -role <r>` grants the **whole role** at that path — you
cannot split one role's privileges across paths. So 3b needs **≥2 roles** (a scoped set + a tiny
box-wide read role), not the single `/`-applied role of today.
Recommended (validated except the felhom-pbs write grant — see §7):
```
# Role A — guest-scoped (applied at /pool/felhom, to BOTH user and token):
pveum role add FelhomAgentGuest -privs "VM.Allocate VM.Audit VM.Config.Disk VM.Config.CPU \
VM.Config.Memory VM.Config.Network VM.Config.Options VM.PowerMgmt VM.Snapshot \
VM.Snapshot.Rollback VM.Backup Pool.Allocate"
pveum acl modify /pool/felhom -user felhom-agent@pve -role FelhomAgentGuest
pveum acl modify /pool/felhom -token 'felhom-agent@pve!agent' -role FelhomAgentGuest
# Role B — storage write (applied at EACH agent-touched storage):
pveum role add FelhomAgentStore -privs "Datastore.Allocate Datastore.AllocateSpace Datastore.Audit"
for S in local local-lvm felhom-pbs ; do # + felhom-usb/felhom-flash IF the agent ever vzdumps to them
pveum acl modify /storage/$S -user felhom-agent@pve -role FelhomAgentStore
pveum acl modify /storage/$S -token 'felhom-agent@pve!agent' -role FelhomAgentStore
done
# Role C — box-wide reads (applied at /):
pveum role add FelhomAgentBase -privs "Sys.Audit SDN.Use"
pveum acl modify / -user felhom-agent@pve -role FelhomAgentBase
pveum acl modify / -token 'felhom-agent@pve!agent' -role FelhomAgentBase
```
Notes proven empirically:
- **`Datastore.Audit` does NOT need to be box-wide** — `GET /storage` returned a *filtered* 2-item list
under the per-storage grants (200, showed only `local`+`local-lvm`). Keeping it per-storage is enough
for the agent's ListStorage/NodeStorage/StorageContent.
- **Privsep intersection:** grant each role to BOTH the user AND the token (as the live install already
does at `/`), else the token's effective perms are empty.
- Alternative if dynamic drive enrollment makes per-storage brittle: put **`Datastore.Audit` at `/`**
(read-only, harmless) and keep only `Datastore.Allocate`/`AllocateSpace` per-storage. Not required by
the tested flow; a robustness option.
## 5. Blast-radius proof (T7) — the scoped token CANNOT reach out-of-pool guests
Against guests NOT in the `felhom` pool, as the spike token:
```
GET /nodes/demo-felhom/lxc/9001/config -> 403 Permission check failed (/vms/9001, VM.Audit)
GET /nodes/demo-felhom/lxc/9001/status/current -> 403 Permission check failed (/vms/9001, VM.Audit)
PUT /nodes/demo-felhom/lxc/9001/config -> 403 Permission check failed (/vms/9001, VM.Config.Disk|VM.Config.CPU|…)
POST /nodes/demo-felhom/lxc/9100/status/start -> 403 Permission check failed (/vms/9100, VM.PowerMgmt)
```
PVE checks `VM.*` against `/vms/<vmid>` and resolves the `/pool/felhom` grant **only for pool members**;
non-members get no grant → 403. Exactly the containment 3b wants. (Guest **9201** IS reachable by the
scoped token because 3a already adopted it into the `felhom` pool — expected and correct: pool membership
= managed.)
## 6. Per-test results (T1T7)
| Test | Op | Result |
|---|---|---|
| **T1** | restore-into-pool (fresh vmid 9300, pool=felhom) | ✅ **200** + UPID (CRUX passes at `/pool/felhom`) |
| **T2** | task status + log read (own UPID) | ✅ 200 (`stopped OK`), log 200 |
| **T3** | config, resize, snapshot+rollback+delete, start, stop, reads | ✅ all 200 (stop=500 only when called mid-start; 200 once running → runtime, not auth) |
| **T4** | vzdump 9300 → `local` | ✅ 200 + task `OK` |
| **T5** | /version,/nodes,/nodes/{n}/status,/storage,/nodes/{n}/storage,{s}/content,/nodes/{n}/lxc | ✅ all 200; /storage + /lxc return **filtered** lists |
| **T6** | destroy 9300 (purge) | ✅ 200 + task `OK`, guest gone |
| **T7** | out-of-pool guest ops (9001, 9100) | ✅ **403** (contained) |
**No ACL widening was needed** — the proposed layout passed the entire flow on the first pass (for the
`local`/`local-lvm` storage set).
## 7. 3b-impl scope preview
**Agent (`felhom-agent`):**
- `RestoreLXCOptions` gains a `Pool string` field; `RestoreLXC` sets `v.Set("pool", opts.Pool)` when
non-empty (mutate.go). Thread `Pool` through `BringUpSpec``runSelftestProvision`/`RunBringUp` so the
provision restore lands in `felhom`. (Restore-into-pool is proven; this is the only new API param.)
- No other agent op needs change — all steady-state ops already target the guest, which is in the pool.
**Script (`felhom-host-install.sh` step_token, L~793 the two `/` grants):**
- Replace `PVE_PRIVS` broad-at-`/` grant with the 3-role layout of §4 (Guest@/pool/felhom,
Store@each `/storage/<s>`, Base@/). Add `Pool.Allocate` (only in the Guest role).
- Storage list must be **`local local-lvm felhom-pbs`** at minimum (the offsite PBS default is a write
target) — a plain `local`/`local-lvm` scope WOULD break the agent's offsite backup.
- `--uninstall` ACL removal (Spec 1 step 5) must delete the new scoped grants (3 roles × {user,token} ×
their paths) instead of the two `/` grants, and delete the 3 roles (after their ACLs).
- `--adopt-pool` (3a) already puts existing guests in the pool → a retrofitted box's guest becomes
reachable by the scoped token automatically once 3b ships.
## 8. NOT validated by this spike — 3b-impl / follow-up must verify
- **`felhom-pbs` (offsite) backup under a Store grant on `/storage/felhom-pbs`.** T4 validated vzdump to
`local` only. Since PBS is the DEFAULT DR target, 3b-impl must include `/storage/felhom-pbs` in the
Store scope AND validate a PBS backup + PBS verify + a restore-from-PBS under the scoped token before
shipping. (High confidence it works — same Datastore.AllocateSpace mechanism — but unproven here.)
- **Drive storages** (`felhom-usb`/`felhom-flash`): the agent enumerates them (Datastore.Audit) and
bind-mounts their data host-side (not a Datastore priv). If any future path vzdumps to a drive, that
storage needs a Store grant. Dynamic drive enrollment may favor the "Datastore.Audit at `/`" variant.
- **Multi-node cluster:** felhom-pve is single-node. Pools + ACLs are cluster-wide in PVE, but 3b-impl
must verify on the colleague's box (if a cluster) that a restore targeting a specific node with a
`/pool/felhom` grant behaves identically (node is in the URL, not the ACL path — expected fine).
- **restore-test flow** (`--selftest=restore-test`) under scope: uses the 9990xx scratch band, which is
NOT in `felhom` pool → its restore/destroy would 403 under the scoped token. **3b must either add the
restore-test scratch guests to the pool, or grant the scratch-band a scope.** (Flagged — this is a real
gap the core provision flow didn't exercise.)
## 9. Cleanup confirmation
All throwaway objects removed and verified: **0** spike ACL entries, **0** `FelhomSpike*` roles, **0**
`felhom-spike` users, scratch guest 9300 destroyed, its scratch vzdump freed, `/root/.spike-token`
deleted. Live state intact: `felhom-agent` **active**, `--selftest=read` **OK** (sees all 5 storages +
guests), `FelhomAgent` role present, guest **9201 running** and still the sole `felhom` pool member. The
spike token secret was never written to any committed file.