hub v0.153.0: no token sends, mints or reveals a household credential or link (operator ruling 13:20, 09 §3 decision 252)
gates / gates (push) Successful in 6m39s

- Session only: claim-resend, customer create, customer edit (changes the e-mail), create-config; pinned with
  selfbind-link, regen-password, appliance bind by TestToken_CredentialAndLinkRoutesAreSessionOnly (red-proved x2).
- Customers write keeps block/unblock/geo. R-1055 closed; R-1059 filed (off-site re-issue, operator decision).
Not deployed — waits for the operator's yes.

Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
This commit is contained in:
admin committed 2026-10-15 13:26:05 +02:00
1 parent 02be5006bb
commit 368ee9878d
12 files changed
+150 -99

No files matched your search

@@ -551,7 +551,7 @@ Configuration, which `floor:read` opens; the mail hold is a banner, and held mai
| `overview` | dashboard, customer list (`/configs`), hosts list, apps, off-site page, system page | — |
| `floor` | Configuration page, global-floor impact | global floor, per-customer floor |
| `vouch` | — | `POST /configuration/artifacts` (golden, agent, MinAgent) |
| `customers` | new/edit forms, DR recipe (secret-free) | create, edit, block/unblock, geo disable, create-config, claim resend |
| `customers` | new/edit forms, DR recipe (secret-free) | block/unblock, geo disable (create, edit, create-config, claim resend: session only since v0.153.0, decision 252) |
| `hosts` | host page, log bundles, log tails | operator actions, log-bundle and log-tail requests |
| `offsite` | run the key check now (it reads, changes no key) | window grant, weekly windows on/off, off-site and PBS-DR re-issue, freeze |
| `os` | the fleet queue (`/os/fleet`) | approve (guest, Docker, Proxmox, kernel), ring, on/off |
@@ -602,13 +602,13 @@ method (405), so a token gets 403.
| `/customers/{id}/unblock` | — | `customers:write` | |
| `/customers/{id}/geo/disable` | — | `customers:write` | |
| `/customers/{id}/floor` | — | `floor:write` | |
| `/customers/{id}/create-config` | — | `customers:write` | |
| `/customers/{id}/create-config` | — | **session only** | Mints the household's owner passphrase and controller API key (operator ruling 2026-10-15 13:20, `09` §3 252). |
| `/customers/{id}/dr-recipe.json` | `customers:read` | — | |
| `/customers/{id}/request-log-tail` | — | `hosts:write` | |
| `/customers/{id}/log-tail/{x}` | `hosts:read` | — | |
| `/customers/{id}` | **session only** | **session only** | The customer page embeds the owner passphrase and the controller API key (customer_unified.html). |
| `/configs` | `overview:read` | — | |
| `/configs/new` | `customers:read` | `customers:write` | |
| `/configs/new` | `customers:read` | **session only** | Create mints the passphrase + API key, takes the household's e-mail and mails the self-bind link (ruling 13:20). |
| `/configuration/global-floor/impact` | `floor:read` | — | |
| `/configuration/global-floor` | — | `floor:write` | |
| `/configuration/artifacts` | — | `vouch:write` | |
@@ -617,7 +617,7 @@ method (405), so a token gets 403.
| `/configuration/tokens` | — | **session only** | |
| `/configuration/tokens/{id}/revoke` | — | **session only** | |
| `/configs/{id}/delete` | **session only** | **session only** | |
| `/configs/{id}/edit` | `customers:read` | `customers:write` | |
| `/configs/{id}/edit` | `customers:read` | **session only** | Edit can change the household's e-mail — and a new e-mail auto-mails the self-bind link (ruling 13:20). |
| `/offsite/remove-unpinned/{id}` | — | **session only** | |
| `/offsite/abandon-cancel/{id}` | — | **session only** | Cancelling a pending set-aside deletion: no group names it; until the operator places it, session only. |
| `/offsite/window-grant/{id}` | — | `offsite:write` | |
@@ -625,7 +625,7 @@ method (405), so a token gets 403.
| `/os/{id}` | `os:read` | `os:write` | |
| `/offsite/key-audit` | — | `offsite:read` | Runs the daily key check now: it reads keys and reports, it changes none. |
| `/configs/{id}/offsite-reissue` | — | `offsite:write` | |
| `/configs/{id}/claim-resend` | — | `customers:write` | |
| `/configs/{id}/claim-resend` | — | **session only** | Sends a fresh claim code to the household's e-mail (R-1055, operator ruling 2026-10-15 13:20). |
| `/configs/{id}/pbsdr-reissue` | — | `offsite:write` | |
| `/configs/{id}/offsite-freeze` | — | `offsite:write` | |
| `/configs/{id}/preview` | **session only** | — | The preview is the generated controller config — it carries the controller API key. |
@@ -633,7 +633,9 @@ method (405), so a token gets 403.
| `/configs/{id}/reset` | **session only** | **session only** | |
| `/configs/{id}` | `overview:read` | `overview:read` | |
**Session only, by reason:** a secret is shown or minted (reveal recovery credential; the customer page — it embeds the
**Session only, by reason:** a household credential or link is sent, minted or its e-mail changed (claim resend,
customer create and edit, create-config — `09` §3 decision 252, `TestToken_CredentialAndLinkRoutesAreSessionOnly`); a
secret is shown or minted (reveal recovery credential; the customer page — it embeds the
owner passphrase and the controller API key; the config preview — the generated controller config carries that key;
regen-password; the self-bind link; an appliance bind — it delivers the passphrase to the box that asks); something is
deleted or reset (customer delete and its preview, reset and its preview, host delete and its impact, off-site
@@ -659,9 +661,13 @@ replace the global key yet (R-1054).
vouched golden needs a declared MinAgent, R-472) — and read the overview pages. It cannot see any secret, delete or
reset anything, vouch a golden or agent, release the mail hold, or touch a box's API. Revoke on the card; it stops at
once (no cache).
- **A `customers:write` token** can change a household's e-mail and then resend its claim code — the code goes to the
new address. That is a takeover path for the household's dashboard password; it is in the group because the operator
table put „resend claim" there. Decision open (R-1055).
- **A `customers:write` token** could, in v0.152.0, change a household's e-mail and then resend its claim code there — a
takeover path. Closed in v0.153.0 (operator ruling 2026-10-15 13:20, `09` §3 decision 252, R-1055): every route that
sends, mints or reveals a household credential or link, or changes the e-mail, is session only. The group keeps block,
unblock and geo disable.
- **An `offsite:write` token** can re-issue a household's off-site or PBS-DR credential (a fresh one-time secret staged
for the box). It never sees the secret; it can break a household's backup until the box picks the new one up. Open
for the operator (R-1059).
- **No token** can reach the break-glass password, the escrow, the off-site keys, or any destructive verb.
### 17.6 No operator password configured = closed
@@ -939,6 +939,12 @@ its length, and both fixes cost something the household would notice — operato
reset, the password, the tokens. CC gets one token, `overview:read` + `floor:write` — not the vouch (decision 250
item 3). **Until that token works live, CC may use the operator password ONLY to set the floor.** After it works,
the operator changes the password so CC no longer holds it. Amends decision 250 item 2's *Route* sentence (R-1051).
252. **No token sends, mints or reveals a household credential or link, or changes a household's e-mail.** *Operator
ruling 2026-10-15 13:20 (R-1055 = A).* Session only (the operator signed in with the browser), never by any token:
claim resend, customer create (`/configs/new`), customer edit (it changes the e-mail, and a new e-mail mails the
self-bind link), create-config from a report (mints passphrase + API key) — plus the three already session only:
the self-bind link, regen-password, appliance bind. „Customers: write" keeps block, unblock and geo disable. Pinned by
`TestToken_CredentialAndLinkRoutesAreSessionOnly` (hub v0.153.0, `05` §17.3). Amends decision 251's groups.
### 2026-10-15 (07:48–08:20) — bake first; Plex through its own key; HSTS one day (recorded before the media build)
@@ -65,7 +65,7 @@ OS packages (exact versions, apt-signed), the installer script (by tag, R-980).
| **added (R-1007):** controller → an app's own job queue | holds — nothing new | the `after_unclean_boot` commands run inside the app's container through the docker socket the controller already holds, and read the app's database with a read-only session: no key into the household's account, nothing a compromised controller could not already do (exec in any app, read its database). The household's lost-file names stay on the box (`app.yaml`, the app page); the operator event carries counts only, and a failing command's output is never logged (security review of controller `8d862e4`, fixed in `3b85a0c`) |
| **added:** agent ↔ root on the host | holds by the sudoers | every root verb is one regex-anchored line or a root wrapper that re-checks its input (`03` §3); agent v0.156.1 adds one delete: `felhom-vzdump-partial-clean` removes only a DEAD half-written vzdump of the named guest on a `dir` storage (no vzdump on the host, nothing holding it open) — a compromised agent gains the deletion of a leftover that is not a backup |
| agent ↔ hub | holds for destructive ops | signed jobs verified in the documented order; unsigned spec has no size bounds (R-984) |
| **added 2026-10-15:** operator script ↔ hub UI | holds from hub v0.152.0 | scripts used the operator password, which can do everything; now a scoped, expiring, hashed token (`05` §17); a token never reaches a secret, a delete or `/api/v1`. Open: a `customers:write` token can redirect a claim code (R-1055); the global API key is still one all-powerful bearer (R-1054) |
| **added 2026-10-15:** operator script ↔ hub UI | holds from hub v0.152.0 | scripts used the operator password, which can do everything; now a scoped, expiring, hashed token (`05` §17); a token never reaches a secret, a delete or `/api/v1`. No token sends, mints or reveals a household credential or link since v0.153.0 (`09` §3 decision 252, R-1055 closed); open: an `offsite:write` token can re-issue a household's off-site credential (R-1059); the global API key is still one all-powerful bearer (R-1054) |
| controller ↔ hub | **did not hold until 2026-10-12** | the global key was public (R-950, rotated) and three routes needed no key (R-951, fixed); per-box keys are bound to their box (R-955) |
| box ↔ PBS | holds as written | ciphertext only; the zero-knowledge wording is stronger than a live box allows (R-985) |
| guest ↔ Proxmox host | holds | the guest has no Proxmox credentials (measured) |
@@ -0,0 +1,6 @@
=== RUN TestToken_CredentialAndLinkRoutesAreSessionOnly
tokens_test.go:579: POST /configs/c1/claim-resend (sends a claim code to the household's e-mail) with an all-scope token: 502 "Claim engine is not configured on this hub\n", want 403 session only
--- FAIL: TestToken_CredentialAndLinkRoutesAreSessionOnly (0.05s)
FAIL
FAIL gitea.dooplex.hu/admin/felhom-hub/internal/web 0.072s
FAIL
@@ -0,0 +1,6 @@
=== RUN TestToken_CredentialAndLinkRoutesAreSessionOnly
tokens_test.go:579: POST /configs/c1/edit (changes the household's e-mail (a new e-mail mails the self-bind link)) with an all-scope token: 200 "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Felhom Hub — Edit c1</title>\n <link rel=\"stylesheet\" href=\"/style.css?v=test\"
--- FAIL: TestToken_CredentialAndLinkRoutesAreSessionOnly (0.05s)
FAIL
FAIL gitea.dooplex.hu/admin/felhom-hub/internal/web 0.078s
FAIL
+1
View File
@@ -36,6 +36,7 @@
| Row | What | Closed | Evidence |
|---|---|---|---|
| **R-1055** | **A stolen „Customers: write" token could take over a household's dashboard: change the e-mail, then resend the claim code.** (P3) | CLOSED 2026-10-15 — operator ruling 13:20 (A, `09` §3 decision 252); **built in hub v0.153.0 (not deployed — the operator's „yes" first)**: claim resend, customer create, customer edit and create-config are session only, with the self-bind link, regen-password and appliance bind already so. Full text: `git show 5d78569b:documentation/backlog/OPEN-ITEMS.md` | `TestToken_CredentialAndLinkRoutesAreSessionOnly` (red-proved twice: `audits/hub-tokens-2026-10-15/red-proof/13-claim-resend.txt`, `14-config-edit.txt`); `05` §17.3, §17.5 |
| **R-1051** | **CC could not raise the global floor without the operator's own password.** (P3) | CLOSED 2026-10-15 — hub v0.152.0 deployed (operator „yes"); the operator created the token „cc-releases" (2026-10-15 13:06, wider than the task's two scopes: also customers/hosts read, offsite and os read+write — the operator's choice). Live with the token: a no-op floor save 0.313.0/MinAgent 0.155.0 → 303 `flash=floor_set`, read back from the hub DB with its WAL; the vouch → 403 `missing scope vouch:write`; the customer page and a recovery-credential reveal → 403 `session only`. The `felhom-build-deploy` skill's floor step and `gitea-image-prune.sh` now use the token. Full text: `git show e92501f5:documentation/backlog/OPEN-ITEMS.md` | `audits/hub-tokens-2026-10-15/C3-token-live.txt`, `deploy.txt`; `05` §17; `09` §3 decision 251 |
| **R-952** | **Nothing failed when a new hub route was added without deciding who may call it.** (P3) | CLOSED 2026-10-15 — **built in hub v0.152.0**, both surfaces: `/api/v1` — `internal/api/routes.go` names each route's caller (public with a reason, customer, host, global, passphrase, appliance, recovery), pinned to the switch from source; every non-public route with no credential, for a household and host that exist, answers 401/403. Web — `internal/web/routes.go` classifies every route for access tokens, pinned the same way. Full text: `git show e92501f5:documentation/backlog/OPEN-ITEMS.md` | `TestAPIRouteTable_MatchesSwitch`, `TestAPIRoutes_NoCredential`, `TestRouteTable_MatchesServeHTTPSwitch` (each red-proved, `REPORT-hub-tokens.md`); `05` §17.3–17.4 |
+1 -1
View File
@@ -222,8 +222,8 @@ stopping line that lies.
| **R-1040** | Security & access | P4 | **Until the household makes Sonarr's or Radarr's first admin, any container on the box can read the app's API key.** Measured on the bench 2026-10-14 with the R-1030 seed in place: `AuthenticationRequired=Enabled`, `AuthenticationMethod=None` — `/initialize.json` from another container answers 200 with `apiKey`. Outsiders are blocked by the box's setup gate in that window; a compromised other app is not. | **OPEN** — filed 2026-10-14 | — | Seed the API key at install (`SONARR__AUTH__APIKEY`, measured to work) so the box knows it, and close the window by telling the household in the first steps; or seed a Forms user | CC |
| **R-1050** | Security & access | P4 | **Raise HSTS to one year on or after 2026-11-15, if no https trouble was reported.** `09` §3 decision 249 (D2 = A, operator 2026-10-15): HSTS stays `max-age=86400` now; after a month with no https trouble it becomes one year (`31536000`); never preload, never `includeSubDomains`. The value is `HSTSMaxAgeSeconds` in `felhom-controller/controller/internal/infra/infra.go`, pinned by `TestRenderForwardedHeaders_HTTPSOnly`. „Trouble” = any report of a name that will not open, or a box answering http after the floor. | **WATCHING** — filed 2026-10-15 | the date | On or after 2026-11-15: read the register and the operator's mail for https trouble since 2026-10-15; none → a controller release with one year (test updated), bake → vouch → floor | CC |
| **R-1054** | Security & access | P3 | **The hub's operator/global API key is still one bearer that can do everything on `/api/v1`, including reading every box's break-glass root password (`GET /admin/hosts/{id}/recovery-credential`).** Hub v0.152.0 gave the web surface scoped tokens (`05` §17) but tokens are refused on `/api/v1` by design. Who uses the global key today is not listed in one place. Fix: list its consumers; move each operator use to a scoped token on the web side (or a token class on the API), and keep the global key only where a box path needs it — or retire it. | OPEN — filed 2026-10-15 by the token build | — | List the global key's consumers (pod env, scripts, runbooks) | CC |
| **R-1055** | Security & access | P3 | **A stolen `customers:write` token can take over a household's dashboard: edit the household's e-mail, then „resend claim" — the claim code goes to the new address.** The operator table (`09` §3 decision 251) put „resend claim" in Customers write; the self-bind link, which mints a capability, was made session only. Nobody holds a `customers:write` token today. Fix (operator decision): keep it, or make `/configs/{id}/claim-resend` session only (one line in `hub/internal/web/routes.go`). | WAITING-ON-OPERATOR — filed 2026-10-15 by the token build | operator decision | Decide: claim resend stays in Customers write, or session only | operator |
| **R-1057** | Security & access | P3 | **A restore of a REMOVED gated app comes back without its setup gate, even when the backup was taken before the household's setup — so the app returns open, with no account.** Measured 2026-10-15 on 9202 with Prowlarr (`audits/prowlarr-2026-10-15/box/run3/restore.txt`): installed (gated, no account made), the guarded Update's per-app backup, remove keeping the backups, the household's restore button — afterwards a stranger's `/ping` through traefik answered 200, where before the remove it answered 401 (the gate). This follows the documented rule (`felhom-controller` `internal/stacks/life_records.go` `carryLifeRecords`: no prior record = no gate, „the data comes back with its admin"); the rule's assumption fails for a backup made before the setup. For a first-visitor app (Prowlarr, n8n, …) the first stranger sets the sign-in; for Prowlarr `/initialize.json` also hands out the API key (seen before the setup in the install window — NOT measured after this restore). Needs: the household to install, let a backup run before finishing the setup, remove, restore. | **OPEN** — filed 2026-10-15 by the Prowlarr walk | — | Record the gate's state in the per-app backup and re-close it on restore when it was closed then; red-proof with this walk (`tools/walk_restore.py`) | CC |
| **R-1059** | Security & access | P3 | **A token with „Off-site: write" can re-issue a household's off-site or PBS-DR credential** (`/configs/{id}/offsite-reissue`, `/pbsdr-reissue`: the box password is reset, a fresh one-time secret is staged for the box). The token holder never sees the secret, but can break the household's backup until the box picks the new one up. Decision 252 covered „Customers: write" only; the operator's own token „cc-releases" holds `offsite:write`. | WAITING-ON-OPERATOR — filed 2026-10-15 by the decision-252 build | operator decision | Decide: keep the two re-issues in Off-site write, or session only (two lines in `hub/internal/web/routes.go`) | operator |
## Box system & updates — 13 rows (P2 1, P3 9, P4 3)