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:
+7
-5
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user