From c412941e4cc1cfa47f5c6b2bbee5c757eabb9ecc Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sun, 7 Jun 2026 21:10:27 +0200 Subject: [PATCH] feat: route Users management through the signed control-plane API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- internal/admin/repo.go | 20 +++++++----------- internal/admin/repo_bus.go | 43 ++++++++++++++++++++++++++++++-------- internal/admin/server.go | 17 ++------------- main.go | 8 +++---- 4 files changed, 47 insertions(+), 41 deletions(-) diff --git a/internal/admin/repo.go b/internal/admin/repo.go index 45157a8..a4ff29c 100644 --- a/internal/admin/repo.go +++ b/internal/admin/repo.go @@ -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 diff --git a/internal/admin/repo_bus.go b/internal/admin/repo_bus.go index 3092be6..a8e6d7a 100644 --- a/internal/admin/repo_bus.go +++ b/internal/admin/repo_bus.go @@ -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) } diff --git a/internal/admin/server.go b/internal/admin/server.go index 390f81b..238cf21 100644 --- a/internal/admin/server.go +++ b/internal/admin/server.go @@ -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"}) diff --git a/main.go b/main.go index a757140..b3a1fc4 100644 --- a/main.go +++ b/main.go @@ -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)