Files
felhom.eu/REPORT-hub-tokens.md
T
admin d62cc08b2e
gates / gates (push) Successful in 6m44s
hub v0.152.0: operator access tokens with scopes (09 §3 decision 251, operator 2026-10-15 11:38)
- Configuration → Access tokens: named, scoped (group read/write), expiring (≤365 d), shown once,
  stored SHA-256 only, every use logged by name. Bearer fhp_… on the web surface only; /api/v1 refuses it.
- Route table = the code's single source (web/routes.go), pinned to ServeHTTP's switch; session-only:
  secrets shown/minted, deletes/resets, password, tokens.
- R-952 closed: /api/v1 route table names each caller, pinned to the switch; no-credential refusal test.
- Fail-open fixed: an empty operator password served the UI open (main skipped RequireAuth). Live hub never open.
- 05 §17, 04 §6.2, 13, 09 §3 251; register 155→157 (+R-1054, R-1055, R-1056, −R-952); STATUS.
- website: prowlarr-logo.svg (byte copy from felhom.eu-media) — the site gate refused every push.
Not deployed — waits for the operator's yes.

Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
2026-10-15 12:20:35 +02:00

90 lines
6.0 KiB
Markdown

# REPORT — hub access tokens (hub v0.152.0), 2026-10-15
## For the operator — plain words
**What a token can do.** A token lets a script use the hub without your password. You manage tokens on
**Configuration → Access tokens**. You give a name, tick what it may do, and pick 30, 90, 180 or 365 days. The hub
shows the token once. It keeps only a scrambled form. Every use is logged with the token's name.
**What no token can do.** Show a box's emergency root password. Show a household's passphrase or controller key (so
the customer page and the config preview are password-only). Delete or reset anything. Change the password. Create or
revoke tokens. Reach the box API (`/api/v1`).
**Is CC using its token now?** No. Hub 0.152.0 is built and tested, but not deployed. It waits for your „yes".
**What you do now.**
1. Say „yes" — I deploy hub 0.152.0 (it includes 0.151.0).
2. On Configuration → Access tokens, create „cc-releases": *Overview: read* and *Releases: floor: write*, 90 days.
Save it on DooPlex as `~/.felhom-hub/cc-token` with `chmod 600`. Do not paste it to me.
3. I test it and tell you. Then change the hub password. CC's copy of the old password is the line `HUB_PW` in
`~/.config/credentials` on DooPlex — delete that line yourself.
4. Decide R-1055 (below).
**Was the hub ever open without a password?** The code allowed it: with no password set, the hub served the whole
operator UI with no login. The live hub was never open: today an unauthenticated request got the login page, and the
manifest carries a password hash. Fixed now: no password means every operator page answers „unavailable".
## What was built
- Token store `api_tokens` (SHA-256 only), the guard in `RequireAuth`, the route table (`hub/internal/web/routes.go`),
the Configuration card, the box API's refusal of tokens, the box API's route table (`hub/internal/api/routes.go`).
- Design: `documentation/architecture/05-hub-architecture.md` §17 (read first, with §16). Also `04` §6.2, `13` §4/§5,
`09` §3 decision 251.
- Go gate in `hub/`: `go build ./... && go vet ./... && go test ./...` → rc 0.
## Red-proofs — each guard broken on purpose, its test seen failing, then restored
Evidence: `documentation/audits/hub-tokens-2026-10-15/red-proof/` (one file per case; each shows `=== RUN` for the test).
| # | guard broken | test | failed with |
|---|---|---|---|
| 1 | operator-action entry removed from the route table | `TestRouteTable_MatchesServeHTTPSwitch` | route 21: switch has `/hosts/…/operator-action`, table has `/request-logs` |
| 2 | session-only allowed for tokens | `TestToken_SessionOnlyRoutesRefuseEveryToken` | `POST /` with an all-scope token: 200 |
| 3 | expiry not checked | `TestToken_NoTokenUnknownExpiredRevoked401` | expired token: 200, want 401 |
| 4 | group:read accepted on a write route | `TestToken_ScopeDecides` | floor:read token on floor save: 303; the floor changed |
| 5 | plain token stored | `TestAPIToken_StoredRowHasNoPlainToken` | t.db holds the plain token |
| 6 | old empty-password skip restored | `TestRequireAuth_NoPasswordRefusesEveryOperatorRoute` | no password: GET / → 200 |
| 7 | Basic passes without the header | `TestToken_CSRFRuleUnchangedForBasic` | Basic without X-Felhom-Operator: 303 |
| 8 | handler's own token check removed | `TestToken_CannotManageTokens` | handleTokenCreate under a token: 200; 2 rows |
| 9 | `GET /customers` removed from the API table | `TestAPIRouteTable_MatchesSwitch` | route 36 mismatch |
| 10 | `requireGlobalKey` returns true | `TestAPIRoutes_NoCredential` | GET /customers with no credential: 200 |
| 11 | `fhp_` check removed from the API | `TestAPI_AccessTokenIsRefusedEverywhere` | POST /report with an access token: 401 (not the 403 refusal) |
| 12 | main mounts `ServeHTTP` bare | `TestWebMountAlwaysAuthenticated` | 0 via Handler(), 1 other |
## Claims in the task that turned out wrong
- „Hungarian UI, same style as the page": the Configuration page and the whole hub UI are English (the design system:
operator surfaces are English). The card is English, like the page.
- „Mail hold — read: the held mails": the hub keeps no held mail; a hold drops it (`internal/mailhold`). Mail hold has
only a write tick (release). Releases: vouch has only a write tick too — the Day-0 manifest has no page of its own.
- „`dr-recipe.json` if it carries a secret": it carries none (`handleDRRecipeDownload`: „secret-free"); it is
`customers:read`.
- Not in the task, found: the customer page embeds the owner passphrase and the controller API key, and the config
preview carries the controller key — both session only.
## Recommendations not followed (one line each)
- „Customers write: resend claim" — followed as written, but a stolen `customers:write` token can then redirect a
household's claim code (edit e-mail, resend). Filed R-1055 for your decision; my pick is password-only.
- „Off-site read: key audit" — followed; it is a POST that runs the check now and changes no key.
## Scripts and runbooks that use the password (Part C4 — after the token works live)
| where | what | scope it needs |
|---|---|---|
| `skills/felhom-build-deploy/SKILL.md` release step 5 | global floor save | `floor:write` — the CC token has it |
| `skills/felhom-build-deploy/SKILL.md` ISO section | discard a proof install's appliance | **session only** — no token can; stays yours or stays the password until you place it |
| `~/git/misc-scripts/gitea-image-prune.sh` | reads floor, golden, agent from the Configuration page | `floor:read` — the CC token has it |
| `documentation/runbooks/RUNBOOK-manual-build.md` §prune | describes the script above | — |
Not moved yet — rule 3: until the token works live, nothing switches.
## Register
Before 155 · after 157 · opened 3 (R-1054 global API key's future, R-1055 claim-resend chain, R-1056 five routes
with no group) · closed 1 (R-952). R-1051 stays open (BLOCKED on the deploy and the token).
## Teardown
Provisioned nothing: no box, no host, no hub change. Tests ran on temp databases only.