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
+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"})