feat: route Users management through the signed control-plane API

The gateway previously managed the bus allowlist only via a direct
membership store opened with --db, falling back to a "none" backend that
left the Users tab degraded in cluster (the control plane exposed no user
HTTP endpoint). The unibus control plane now exposes an admin-only user
API (GET/POST /users, POST /users/{signpub}/revoke), and pkg/client wraps
it with ListUsers/AddUser/RevokeUser that sign each request.

busRepo now drives those client methods whenever no direct store is
configured (the cluster default), so user management works in cluster
without KV/SQLite access — the bus verifies the operator's admin identity
with requireAdmin and writes to the same store the room handlers use. A
direct store (--db) is kept as an explicit single-node fallback. The
reported users_backend becomes "control-plane" (or "sqlite" with --db),
and ErrUsersUnavailable / the "none" path are removed since a connected
gateway can always reach the API.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 21:10:27 +02:00
parent 93acc059f1
commit c412941e4c
4 changed files with 47 additions and 41 deletions
+4 -4
View File
@@ -36,7 +36,7 @@ func main() {
nodesCSV = flag.String("nodes", "", "cluster nodes to probe for /healthz as name=url,name=url (default: derive one from --ctrl-url)")
identityFile = flag.String("identity-file", "", "path to the admin identity JSON file (0600). Mutually exclusive with --identity-pass")
identityPass = flag.String("identity-pass", "", "pass(1) entry holding the admin identity JSON, e.g. unibus/operator-identity")
dbPath = flag.String("db", "", "membership SQLite path for the Users tab (single-node/dev). Empty = Users tab read-only-unavailable unless --mock")
dbPath = flag.String("db", "", "OPTIONAL membership SQLite path for single-node user management. Empty (default) = manage users via the signed control-plane API, which works in cluster")
mock = flag.Bool("mock", false, "serve sample data instead of talking to the bus (UI iteration)")
)
flag.Parse()
@@ -59,7 +59,7 @@ func main() {
log.Fatalf("%v", err)
}
var store membership.Store
backend := "none"
backend := "control-plane"
if *dbPath != "" {
store, err = membership.Open(*dbPath)
if err != nil {
@@ -67,9 +67,9 @@ func main() {
}
defer store.Close()
backend = "sqlite"
log.Printf("users backend: sqlite %s", *dbPath)
log.Printf("users backend: sqlite %s (single-node direct store)", *dbPath)
} else {
log.Printf("users backend: none (Users tab degraded; pass --db for single-node user management)")
log.Printf("users backend: control-plane (signed admin HTTP to the bus; works in cluster)")
}
nodes := parseNodes(*nodesCSV, *ctrlURL)