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