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