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
+7 -13
View File
@@ -7,17 +7,8 @@ package admin
import (
"context"
"errors"
)
// ErrUsersUnavailable is returned by the users operations when the gateway was
// started without a membership store (no --db / no KV access). The bus control
// plane exposes no user-management HTTP endpoint — users live only in the store
// — so the Users tab is read-and-write only when the gateway can reach that
// store directly. Without it the tab degrades to an explanatory empty state
// rather than failing opaquely.
var ErrUsersUnavailable = errors.New("admin: user management requires direct store access (start with --db or a KV-backed store)")
// Posture is the security posture a membershipd node publishes on /healthz. It
// mirrors membership.Posture but is duplicated here so the wire shape the SPA
// consumes is owned by the gateway, not coupled to the bus package's struct tags.
@@ -94,11 +85,11 @@ type AddUserReq struct {
}
// MeInfo describes the gateway's own identity and which capabilities are wired,
// so the SPA can render the operator endpoint and gate the Users tab.
// so the SPA can render the operator endpoint and label the Users tab's backend.
type MeInfo struct {
Endpoint string `json:"endpoint"`
SignPub string `json:"sign_pub"`
UsersBackend string `json:"users_backend"` // "sqlite" | "kv" | "none"
UsersBackend string `json:"users_backend"` // "control-plane" (signed HTTP) | "sqlite" (single-node fallback)
Mock bool `json:"mock"`
}
@@ -121,8 +112,11 @@ type Repo interface {
// (forward secrecy). This is the rekey-on-kick primitive the bus exposes.
KickMember(ctx context.Context, roomID, endpoint string) error
// Users (the bus allowlist). Available only with direct store access;
// otherwise these return ErrUsersUnavailable.
// Users (the bus allowlist). The live gateway manages these against the bus
// control plane's admin-only user endpoints, signing each request as the
// operator's admin identity — so user management works in cluster without
// direct store/KV access. A single-node deployment may instead point the
// gateway at the SQLite store directly (--db) as an explicit fallback.
UsersWritable() bool
ListUsers(ctx context.Context) ([]UserView, error)
AddUser(ctx context.Context, req AddUserReq) error
+34 -9
View File
@@ -40,8 +40,11 @@ type busRepo struct {
cli *client.Client
nodes []NodeTarget
store membership.Store // optional; nil => Users tab degraded
storeBackend string // "sqlite" | "kv" | "none"
// store is an OPTIONAL direct membership store for single-node user
// management. When nil (the cluster default), user operations go through the
// signed control-plane API on r.cli instead — see ListUsers/AddUser/RevokeUser.
store membership.Store
storeBackend string // "control-plane" (cli) | "sqlite" (direct store fallback)
}
// BusConfig wires a live gateway.
@@ -90,9 +93,11 @@ func NewBusRepo(cfg BusConfig) (*busRepo, error) {
}
ctrlURLs := append([]string{cfg.CtrlURL}, cfg.CtrlURLs...)
backend := cfg.StoreBackend
if cfg.Store == nil {
backend = "none"
// With no direct store, user management rides the signed control-plane API
// (works in cluster). A direct store is an explicit single-node fallback.
backend := "control-plane"
if cfg.Store != nil {
backend = cfg.StoreBackend
}
return &busRepo{
id: cfg.Identity,
@@ -326,12 +331,32 @@ func (r *busRepo) signedGET(path string) ([]byte, error) {
}
// ---- users ----------------------------------------------------------------
//
// User management has two backends. The cluster default has no direct store
// (r.store == nil): every operation goes through the unibus client's admin-only
// HTTP endpoints (GET/POST /users, POST /users/{signpub}/revoke), each request
// signed as the operator's admin identity and verified by the bus's requireAdmin
// against the same store the room handlers use — so it works in cluster without
// KV/SQLite access. A single-node deployment may instead pass --db to manage the
// SQLite store directly; that path is kept as an explicit fallback.
func (r *busRepo) UsersWritable() bool { return r.store != nil }
// UsersWritable reports whether the Users tab can mutate the allowlist. The live
// gateway always can: either it holds a direct store, or it signs as an admin
// against the control plane. (A non-admin signer is rejected at request time by
// the bus with 403; that is an authorization outcome, not a missing capability.)
func (r *busRepo) UsersWritable() bool { return true }
func (r *busRepo) ListUsers(context.Context) ([]UserView, error) {
if r.store == nil {
return nil, ErrUsersUnavailable
users, err := r.cli.ListUsers()
if err != nil {
return nil, err
}
out := make([]UserView, 0, len(users))
for _, u := range users {
out = append(out, UserView(u))
}
return out, nil
}
users, err := r.store.ListUsers()
if err != nil {
@@ -353,14 +378,14 @@ func (r *busRepo) ListUsers(context.Context) ([]UserView, error) {
func (r *busRepo) AddUser(_ context.Context, req AddUserReq) error {
if r.store == nil {
return ErrUsersUnavailable
return r.cli.AddUser(req.SignPub, req.Handle, req.Role)
}
return r.store.AddUser(req.SignPub, req.Handle, req.Role)
}
func (r *busRepo) RevokeUser(_ context.Context, signPub string) error {
if r.store == nil {
return ErrUsersUnavailable
return r.cli.RevokeUser(signPub)
}
return r.store.RevokeUser(signPub)
}
+2 -15
View File
@@ -3,7 +3,6 @@ package admin
import (
"context"
"encoding/json"
"errors"
"io/fs"
"log"
"net/http"
@@ -140,10 +139,6 @@ func (s *Server) handleKick(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
users, err := s.repo.ListUsers(r.Context())
if err != nil {
if errors.Is(err, ErrUsersUnavailable) {
writeErr(w, http.StatusServiceUnavailable, err.Error())
return
}
writeErr(w, http.StatusBadGateway, err.Error())
return
}
@@ -160,11 +155,7 @@ func (s *Server) handleAddUser(w http.ResponseWriter, r *http.Request) {
return
}
if err := s.repo.AddUser(r.Context(), req); err != nil {
code := http.StatusBadGateway
if errors.Is(err, ErrUsersUnavailable) {
code = http.StatusServiceUnavailable
}
writeErr(w, code, err.Error())
writeErr(w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusCreated, map[string]string{"status": "added"})
@@ -182,11 +173,7 @@ func (s *Server) handleRevokeUser(w http.ResponseWriter, r *http.Request) {
return
}
if err := s.repo.RevokeUser(r.Context(), req.SignPub); err != nil {
code := http.StatusBadGateway
if errors.Is(err, ErrUsersUnavailable) {
code = http.StatusServiceUnavailable
}
writeErr(w, code, err.Error())
writeErr(w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
+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)