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

- 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:
2026-10-15 12:20:35 +02:00
parent e92501f5a1
commit d62cc08b2e
37 changed files with 2567 additions and 21 deletions
+7 -5
View File
@@ -663,12 +663,14 @@ func main() {
// API routes (no dashboard auth for report ingest)
mux.Handle("/api/v1/", apiHandler)
// Web routes (auth required)
if cfg.Auth.PasswordHash != "" {
mux.Handle("/", webServer.RequireAuth(http.HandlerFunc(webServer.ServeHTTP)))
} else {
mux.Handle("/", http.HandlerFunc(webServer.ServeHTTP))
// Web routes — ALWAYS behind RequireAuth (v0.152.0). The old `if cfg.Auth.PasswordHash != ""`
// mounted the UI with no auth at all when the ConfigMap seed was empty, even with a password set
// in the DB override. With no password anywhere, RequireAuth now closes every operator route.
// Pinned by TestWebMountAlwaysAuthenticated.
if cfg.Auth.PasswordHash == "" {
logger.Printf("[WARN] auth.password_hash is empty — the operator UI opens only if a password is set in the database; otherwise every operator route answers 503")
}
mux.Handle("/", webServer.Handler())
// Start HTTP server
server := &http.Server{
+45
View File
@@ -0,0 +1,45 @@
package main
import (
"go/ast"
"go/parser"
"go/token"
"strconv"
"testing"
)
// TestWebMountAlwaysAuthenticated (hub v0.152.0): main mounts the operator web surface at "/" ONLY as
// webServer.Handler() (RequireAuth in front, unconditionally). Before, an empty auth.password_hash mounted
// webServer.ServeHTTP bare — the whole operator UI with no login. RED-PROOF: put back
// `mux.Handle("/", http.HandlerFunc(webServer.ServeHTTP))` → FAIL.
func TestWebMountAlwaysAuthenticated(t *testing.T) {
f, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0)
if err != nil {
t.Fatal(err)
}
var viaHandler, bare int
ast.Inspect(f, func(n ast.Node) bool {
c, ok := n.(*ast.CallExpr)
if !ok || len(c.Args) != 2 {
return true
}
sel, ok := c.Fun.(*ast.SelectorExpr)
if !ok || sel.Sel.Name != "Handle" {
return true
}
if bl, ok := c.Args[0].(*ast.BasicLit); !ok || func() string { v, _ := strconv.Unquote(bl.Value); return v }() != "/" {
return true
}
if call, ok := c.Args[1].(*ast.CallExpr); ok {
if s, ok := call.Fun.(*ast.SelectorExpr); ok && s.Sel.Name == "Handler" {
viaHandler++
return true
}
}
bare++
return true
})
if viaHandler != 1 || bare != 0 {
t.Fatalf(`mux.Handle("/", …): %d via webServer.Handler(), %d other — want exactly 1 and 0`, viaHandler, bare)
}
}