hub v0.152.0: operator access tokens with scopes (09 §3 decision 251, operator 2026-10-15 11:38)
gates / gates (push) Successful in 6m44s
gates / gates (push) Successful in 6m44s
- 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
This commit is contained in:
@@ -176,6 +176,14 @@ the deletion is the hub's), `run_job` (`fill-watch`, `offsite-integrity`, `offsi
|
||||
Anything else is refused at the hub's POST and again on the box. No action deletes data, starts a countdown or
|
||||
shortens one (`TestOpActions_ClosedList`, `TestOperatorActions_ClosedList`). Unanswered after 24 h: expired.
|
||||
|
||||
### 6.2 Who may press the operator's buttons: session, Basic, or a scoped token [hub v0.152.0, `09` §3 decision 251]
|
||||
|
||||
The hub's operator surface has three ways in: a browser session, Basic auth with the operator password (scripts,
|
||||
`05` §16.1), and — new — an **access token** with ticked scopes (`05` §17). A token is NOT a signing key and changes
|
||||
nothing in this document's model: a destructive op still needs the operator's signature, which no hub credential can
|
||||
make. What a token narrows is the hub side: the operator actions above are `hosts:write`; the reveal of a break-glass
|
||||
credential, every delete and reset, the password and the tokens are session only.
|
||||
|
||||
## 7. Hardware readiness (Viktor's "build the foundation now")
|
||||
|
||||
Software `ssh-ed25519` now; a FIDO2 `sk-ssh-ed25519@openssh.com` key later is a **no-op on the
|
||||
|
||||
@@ -504,3 +504,171 @@ newest snapshot up at 02:30, checks it (`PRAGMA integrity_check`), encrypts it a
|
||||
namespace (`runbooks/RUNBOOK-hub-db-offsite-backup.md`). Pinned by `internal/dbsnap/dbsnap_test.go` and
|
||||
`cmd/hub/r173_wiring_test.go`.
|
||||
|
||||
|
||||
## 17. Operator access tokens [DESIGN — hub v0.152.0, operator ruling 2026-10-15 11:38 (`09` §3 decision 251)]
|
||||
|
||||
**The problem.** Scripts — Claude Code's floor step first — drove the hub with the operator's own password (HTTP Basic,
|
||||
`05` §16.1). That password does everything: reveals every box's break-glass root password, deletes customers, changes
|
||||
the floor and the password itself. A leaked copy opens the whole hub, and the log cannot tell a script from the
|
||||
operator.
|
||||
|
||||
**The answer.** A token: `fhp_` + 32 random bytes (base32, lower case, 56 characters in all), named, with ticked
|
||||
scopes and an expiry (default 90 days, at most 365, „never" not offered). Created and revoked only from a browser
|
||||
session on Configuration → Access tokens; shown **once** (in that one response, `Cache-Control: no-store`, never in a
|
||||
redirect URL or a log). The hub stores only its **unkeyed SHA-256** (`api_tokens.token_hash`): the token is 32 random
|
||||
bytes, so there is nothing a dictionary or a slow search can find, and a slow hash would buy nothing; the hash stops a
|
||||
leaked database copy from being replayed. Pinned by `TestAPIToken_StoredRowHasNoPlainToken` (the DB file and its WAL
|
||||
are scanned for the plain token, with the hash as the positive control). The row keeps name, scopes, created,
|
||||
created-by, expires, last used, last used from (first `X-Forwarded-For` hop — informational, never an auth input),
|
||||
revoked.
|
||||
|
||||
### 17.1 Use, and why it needs no CSRF token
|
||||
|
||||
`Authorization: Bearer fhp_…` on the operator web surface. `RequireAuth` checks it **before** the session and Basic
|
||||
paths and never falls through to them: a token request is decided by its scopes alone, so it is never full operator.
|
||||
Unknown, revoked or expired → **401**; a scope it lacks → **403 naming the scope** (`missing scope floor:write`); a
|
||||
session-only route → **403 „session only"**. Every use is logged with the token's NAME, the method, the path and the
|
||||
result; a refusal too.
|
||||
|
||||
No CSRF token (`§16.1` against): the R-135 gate exists because a browser re-sends cached Basic credentials and cookies
|
||||
on a cross-site form. A browser attaches no bearer header by itself, and a page on another site can set an
|
||||
`Authorization` header only through a CORS preflight, which the hub never answers. So a request that carries the token
|
||||
was written by whoever holds it. Basic auth keeps its header rule unchanged (`TestToken_CSRFRuleUnchangedForBasic`).
|
||||
|
||||
**A token never:** opens a session-only route (below); reaches `/api/v1` — the box API refuses any `fhp_` bearer on
|
||||
every route before its switch (`TestAPI_AccessTokenIsRefusedEverywhere`); logs in a browser (`/login` takes the
|
||||
password only); sees the token list (a token-authenticated read of `/configuration` renders no token card); creates or
|
||||
revokes a token (the route is session-only AND both handlers check again — `TestToken_CannotManageTokens`).
|
||||
|
||||
### 17.2 The groups
|
||||
|
||||
Write includes read. A tick is offered only when it opens at least one route (`TestScopeGroups_EveryOfferedTickOpensARoute`),
|
||||
so two groups have no read tick: the Day-0 manifest and the mail hold have no page of their own (the manifest shows on
|
||||
Configuration, which `floor:read` opens; the mail hold is a banner, and held mail is dropped, not kept — `internal/mailhold`).
|
||||
|
||||
| group | read opens | write opens |
|
||||
|---|---|---|
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `mailhold` | — | release |
|
||||
|
||||
**CC's token** („cc-releases"): `overview:read` + `floor:write`. Not `vouch` — the vouch stays the operator's
|
||||
(decision 250).
|
||||
|
||||
### 17.3 The route table — the code's single source
|
||||
|
||||
`hub/internal/web/routes.go` `operatorRoutes`: one entry per case of `ServeHTTP`'s switch, same order, same match;
|
||||
the classifier walks it first-match-wins exactly as the switch does. `TestRouteTable_MatchesServeHTTPSwitch` reads the
|
||||
switch from `server.go`'s syntax tree and fails on any added, removed, reordered or changed case (R-952, web side). A
|
||||
path no entry matches, and a method an entry does not name, is session only. „—" = the route does not serve that
|
||||
method (405), so a token gets 403.
|
||||
|
||||
| path | GET | POST | why session-only |
|
||||
|---|---|---|---|
|
||||
| `/` | `overview:read` | — | |
|
||||
| `/style.css` | any token | — | |
|
||||
| `/static/chart.min.js` | any token | — | |
|
||||
| `/static/fonts/{id}` | any token | — | |
|
||||
| `/configuration` | `floor:read` | **session only** | The Configuration page shows the floors; its POST is the asset re-seed, which fits no group. |
|
||||
| `/apps` | `overview:read` | — | |
|
||||
| `/apps/` | `overview:read` | — | |
|
||||
| `/apps/{id}/reset-telemetry` | — | **session only** | Telemetry reset and issue dismissal change the fleet view; no group fits them. |
|
||||
| `/apps/{id}/dismiss-issues` | — | **session only** | |
|
||||
| `/apps/{id}` | `overview:read` | — | |
|
||||
| `/offsite` | `overview:read` | — | |
|
||||
| `/offsite/endpoints` | — | **session only** | The WireGuard endpoint record is infrastructure; no group fits a save. |
|
||||
| `/offsite/endpoints/{id}/delete` | — | **session only** | |
|
||||
| `/hosts` | `overview:read` | — | |
|
||||
| `/hosts/` | `overview:read` | — | |
|
||||
| `/system` | `overview:read` | — | |
|
||||
| `/appliances/{id}/bind` | — | **session only** | A bind delivers the household's retrieval passphrase to the box that asks — a secret delivery. |
|
||||
| `/appliances/{id}/discard` | — | **session only** | |
|
||||
| `/hosts/{id}/delete-impact` | **session only** | — | |
|
||||
| `/hosts/{id}/delete` | — | **session only** | |
|
||||
| `/hosts/{id}/reveal-recovery-credential` | — | **session only** | |
|
||||
| `/hosts/{id}/operator-action` | — | `hosts:write` | |
|
||||
| `/hosts/{id}/request-logs` | — | `hosts:write` | |
|
||||
| `/hosts/{id}/log-bundles/{x}` | `hosts:read` | — | |
|
||||
| `/hosts/{id}` | `hosts:read` | — | |
|
||||
| `/login` | exempt (public) | exempt (public) | |
|
||||
| `/bind/<token>` | exempt (public) | exempt (public) | |
|
||||
| `/customers/{id}/block` | — | `customers:write` | |
|
||||
| `/customers/{id}/selfbind-link` | — | **session only** | The self-bind link is a capability that binds a box to the household — minted here. |
|
||||
| `/customers/{id}/unblock` | — | `customers:write` | |
|
||||
| `/customers/{id}/geo/disable` | — | `customers:write` | |
|
||||
| `/customers/{id}/floor` | — | `floor:write` | |
|
||||
| `/customers/{id}/create-config` | — | `customers:write` | |
|
||||
| `/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` | |
|
||||
| `/configuration/global-floor/impact` | `floor:read` | — | |
|
||||
| `/configuration/global-floor` | — | `floor:write` | |
|
||||
| `/configuration/artifacts` | — | `vouch:write` | |
|
||||
| `/configuration/mail-hold/release` | — | `mailhold:write` | |
|
||||
| `/configuration/password` | — | **session only** | |
|
||||
| `/configuration/tokens` | — | **session only** | |
|
||||
| `/configuration/tokens/{id}/revoke` | — | **session only** | |
|
||||
| `/configs/{id}/delete` | **session only** | **session only** | |
|
||||
| `/configs/{id}/edit` | `customers:read` | `customers:write` | |
|
||||
| `/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` | |
|
||||
| `/offsite/windows-enabled` | — | `offsite:write` | |
|
||||
| `/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}/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. |
|
||||
| `/configs/{id}/regen-password` | — | **session only** | |
|
||||
| `/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
|
||||
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
|
||||
endpoint delete, remove-unpinned); the password; the tokens.
|
||||
|
||||
**Session only because no group fits — for the operator to place** (R-1056): `/apps/{id}/reset-telemetry`,
|
||||
`/apps/{id}/dismiss-issues`, `POST /configuration` (asset re-seed), `POST /offsite/endpoints` (the WireGuard endpoint
|
||||
record), `/offsite/abandon-cancel/{id}`.
|
||||
|
||||
### 17.4 The box API's route table (R-952, API side)
|
||||
|
||||
`hub/internal/api/routes.go` `apiRoutes` names who may call each `/api/v1` route: `public` (with its reason — only the
|
||||
appliance register), `customer` (controller key or global key), `host` (host key, self-scoped, or global), `global`,
|
||||
`passphrase` (X-Retrieval-Password), `appliance` (its own token), `recovery` (re-enroll — only while the operator armed
|
||||
recovery mode). `TestAPIRouteTable_MatchesSwitch` pins it to `handler.go`'s switch; `TestAPIRoutes_NoCredential` sends
|
||||
every non-public route a request with no credential, for a household and host that EXIST (so a 404 cannot stand in for
|
||||
a refusal), and requires 401/403. The table documents and pins; each handler still makes its own check. Tokens do not
|
||||
replace the global key yet (R-1054).
|
||||
|
||||
### 17.5 What a stolen token can do
|
||||
|
||||
- **CC's token:** set the global or a per-customer floor — within the floor rules the page enforces (a floor above the
|
||||
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).
|
||||
- **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
|
||||
|
||||
Until v0.152.0 an empty password **opened** the operator UI: `RequireAuth` served everything when the effective hash was
|
||||
empty, and `main` did not mount `RequireAuth` at all when the ConfigMap seed was empty — even if a password was set in
|
||||
the DB override. Now `main` always mounts `webServer.Handler()` (`TestWebMountAlwaysAuthenticated`) and an empty password
|
||||
answers **503** on every operator route; `/bind/` stays public, `/login` renders but cannot succeed
|
||||
(`TestRequireAuth_NoPasswordRefusesEveryOperatorRoute`). **The live hub was never open:** 2026-10-15, hub 0.150.0, an
|
||||
unauthenticated `GET /`, `/configuration`, `/hosts` each answered 302 → `/login`, and the ConfigMap carries a hash.
|
||||
|
||||
@@ -925,14 +925,20 @@ its length, and both fixes cost something the household would notice — operato
|
||||
2. **CC raises the floor itself**, with the release's MinAgent declared, when both guards hold: (a) the release ran on
|
||||
the three test boxes (Tester 1, demo-hp, demo-felhom) through the normal update path; (b) the box walk's checks are
|
||||
green on all three afterwards (`scripts/https_only_check.py` included). Not at night unless the night brief says so.
|
||||
*Route:* the only way to save the global floor today is the operator page with the operator's own password; CC
|
||||
does not use it for this until the operator grants a scoped route (R-1051).
|
||||
*Route:* the floor save on the operator page, with CC's scoped token (decision 251, `05` §17); until the token
|
||||
works live, the operator password for the floor only (R-1051).
|
||||
3. **Agent releases (and their bundles) still stop for the operator's vouch;** a floor whose MinAgent is above the
|
||||
vouched agent is never set.
|
||||
4. **The golden is baked weekly** (Sunday night, `runbooks/nightly-rotation.md`); the operator vouches it. A new box
|
||||
installs the vouched golden and updates itself to the floor (not yet proven end to end — R-1052).
|
||||
5. The push gate judges the golden by its AGE (older than 7 days while the floor is above it), not by the newest
|
||||
CHANGELOG heading (`scripts/golden_currency_gate.py`).
|
||||
251. **Scripts use scoped hub tokens, not the operator password; CC's floor step uses its own.** *Operator ruling
|
||||
2026-10-15 11:38.* Tokens with groups (read/write per group), managed on the Configuration tab, shown once, stored
|
||||
hashed, every use logged by name (`05` §17, hub v0.152.0). Never by any token: a secret shown or minted, a delete or
|
||||
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).
|
||||
|
||||
### 2026-10-15 (07:48–08:20) — bake first; Plex through its own key; HSTS one day (recorded before the media build)
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ scripts are held with the review's working notes.
|
||||
| component | routes | no credential | listeners |
|
||||
|---|---|---|---|
|
||||
| controller | 231 (+8 in setup mode) | 21, of which 7 change state, each behind its own password, code or token | port 8080 on the guest's Docker networks (not published); mail relay 2525/2465/2526 |
|
||||
| hub | 119 (1 health, 53 API, 65 web) | appliance register (rate-limited, by design), re-enroll (only while the operator has armed recovery mode), login, health | one ingress, path `/`, geo filter (HU, IE, LAN) |
|
||||
| hub | 121 (1 health, 53 API, 67 web — v0.152.0 added the two token routes; both route tables are now pinned to the source by tests, R-952) | appliance register (rate-limited, by design), re-enroll (only while the operator has armed recovery mode), login, health | one ingress, path `/`, geo filter (HU, IE, LAN) |
|
||||
| agent | 38 local-API routes (`POST /host/poweroff` added 2026-10-13, R-998) | 0 | local API on the island bridge; WireGuard; poke (tunnel only); felhom-sshd (tunnel only, measured) |
|
||||
|
||||
**Outbound fetches that bring code or config onto a box:** the catalog (git, `main`), app images, the four infra
|
||||
@@ -65,6 +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) |
|
||||
| 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) |
|
||||
|
||||
Reference in New Issue
Block a user