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:
@@ -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 (T1–T7)
|
||||
|
||||
| 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.
|
||||
Reference in New Issue
Block a user