feat(membership,client): HTTP admin-only users API
Close the last control-plane asymmetry: rooms had a signed HTTP surface
but users were only manageable via the local CLI or direct store access.
Add admin-only HTTP endpoints, symmetric with rooms, executed against the
same privileged store the server already serves (SQLite single-node, the
replicated JetStream KV in cluster) — no new KV connection, no internal
identity, so the admin panel can manage the allowlist by signing as an
admin instead of needing --db / direct KV access.
Endpoints (all behind requireAdmin, on top of the existing
signature+nonce+TLS+enforce middleware):
- GET /users list the full allowlist (incl. revoked)
- POST /users add {sign_pub, handle, role}
- POST /users/{signpub}/revoke revoke (status flip, no hard delete)
requireAdmin is default-deny with no dev relaxation: it allows a request
only when the authenticated signer is confirmed by the store as an active
admin; any other case (no signer, non-admin, revoked, store error) is 403,
fail-closed. The request context now also carries the signer's sign_pub
hex, because the endpoint id is a one-way hash of the key and cannot be
reversed to look the signer up in the allowlist.
Validation/idempotency mirror the CLL: sign_pub must be 64-hex, role must
be admin|member (empty defaults to member), re-adding an existing key is a
409 that leaves the row untouched. The hex check is unified into
membership.ValidateSignPubHex, reused by the CLI and the handlers.
pkg/client gains ListUsers/AddUser/RevokeUser (flat UserInfo type) signed
via doJSON, so the panel plugs in directly.
Tests: non-admin -> 403 on all three endpoints; admin add->list->revoke
roundtrip; validation (400 hex, 400 role, 409 re-add, row untouched); plus
a client test against an embedded membershipd under enforce.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -456,6 +456,23 @@ type memberRoomJSON struct {
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
// userJSON mirrors the server's wire type on the admin user-management endpoints.
|
||||
type userJSON struct {
|
||||
SignPub string `json:"sign_pub"`
|
||||
Handle string `json:"handle"`
|
||||
Role string `json:"role"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
RevokedAt string `json:"revoked_at,omitempty"`
|
||||
}
|
||||
|
||||
// addUserReq is the POST /users body (mirror of the server type).
|
||||
type addUserReq struct {
|
||||
SignPub string `json:"sign_pub"`
|
||||
Handle string `json:"handle"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
// ---- room operations ------------------------------------------------------
|
||||
|
||||
// RoomRef is a room this peer belongs to, returned by ListMyRooms. It is the
|
||||
@@ -490,6 +507,59 @@ func (c *Client) ListMyRooms() ([]RoomRef, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ---- user administration (admin-only) ------------------------------------
|
||||
|
||||
// UserInfo is a bus user as returned by the admin user-management endpoints. It
|
||||
// is a flat view (no nested types) for the admin panel: the signing key
|
||||
// (lowercase hex), handle, role ("admin"|"member"), status ("active"|"revoked"),
|
||||
// and timestamps. RevokedAt is empty for an active user.
|
||||
type UserInfo struct {
|
||||
SignPub string
|
||||
Handle string
|
||||
Role string
|
||||
Status string
|
||||
CreatedAt string
|
||||
RevokedAt string
|
||||
}
|
||||
|
||||
// ListUsers returns the full bus allowlist, including revoked users. The caller
|
||||
// must be signing as an admin: a non-admin signer is rejected by the server with
|
||||
// 403, surfaced here as an error.
|
||||
func (c *Client) ListUsers() ([]UserInfo, error) {
|
||||
var resp []userJSON
|
||||
if err := c.doJSON("GET", "/users", nil, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]UserInfo, 0, len(resp))
|
||||
for _, u := range resp {
|
||||
out = append(out, UserInfo{
|
||||
SignPub: u.SignPub,
|
||||
Handle: u.Handle,
|
||||
Role: u.Role,
|
||||
Status: u.Status,
|
||||
CreatedAt: u.CreatedAt,
|
||||
RevokedAt: u.RevokedAt,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AddUser registers a bus user from their Ed25519 signing public key (64-hex).
|
||||
// role is "admin" or "member" (empty defaults to member, matching the server).
|
||||
// The caller must be signing as an admin. Re-adding an already-registered key
|
||||
// returns an error (the server replies 409 and leaves the existing row
|
||||
// untouched — no silent role/status change).
|
||||
func (c *Client) AddUser(signPub, handle, role string) error {
|
||||
return c.doJSON("POST", "/users", addUserReq{SignPub: signPub, Handle: handle, Role: role}, nil)
|
||||
}
|
||||
|
||||
// RevokeUser revokes a bus user by their signing public key (64-hex). Revocation
|
||||
// is a status flip (no hard delete): the identity stays auditable and is denied
|
||||
// on both planes immediately. The caller must be signing as an admin.
|
||||
func (c *Client) RevokeUser(signPub string) error {
|
||||
return c.doJSON("POST", "/users/"+signPub+"/revoke", nil, nil)
|
||||
}
|
||||
|
||||
// newRoomKey returns 32 random bytes for a symmetric room key.
|
||||
func newRoomKey() ([]byte, error) {
|
||||
k := make([]byte, 32)
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package client_test
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/enmanuel/unibus/pkg/client"
|
||||
"github.com/enmanuel/unibus/pkg/membership"
|
||||
)
|
||||
|
||||
// findUserInfo returns the row with the given signing key (case-insensitive).
|
||||
func findUserInfo(users []client.UserInfo, signPub string) (client.UserInfo, bool) {
|
||||
want := strings.ToLower(signPub)
|
||||
for _, u := range users {
|
||||
if strings.ToLower(u.SignPub) == want {
|
||||
return u, true
|
||||
}
|
||||
}
|
||||
return client.UserInfo{}, false
|
||||
}
|
||||
|
||||
// TestClientUsersAdminAPI drives the admin user-management API through the real
|
||||
// pkg/client methods against an in-process membershipd under enforce: an admin
|
||||
// client adds a user, lists it, revokes it, and sees the status flip — and a
|
||||
// non-admin client is denied. This is the path the admin panel uses, so it locks
|
||||
// the client/server contract the panel depends on.
|
||||
func TestClientUsersAdminAPI(t *testing.T) {
|
||||
h := newHarnessMode(t, membership.AuthEnforce)
|
||||
waitHealth(t, h.ctrlURL)
|
||||
|
||||
admin, err := client.New(h.natsURL, h.ctrlURL, mustIdentity(t))
|
||||
if err != nil {
|
||||
t.Fatalf("connect admin: %v", err)
|
||||
}
|
||||
defer admin.Close()
|
||||
registerClient(t, h, admin, "admin", membership.RoleAdmin)
|
||||
|
||||
member, err := client.New(h.natsURL, h.ctrlURL, mustIdentity(t))
|
||||
if err != nil {
|
||||
t.Fatalf("connect member: %v", err)
|
||||
}
|
||||
defer member.Close()
|
||||
registerClient(t, h, member, "member", membership.RoleMember)
|
||||
|
||||
// A brand-new identity the admin will register over HTTP.
|
||||
carol := mustIdentity(t)
|
||||
carolPub := hex.EncodeToString(carol.SignPub)
|
||||
|
||||
// Admin adds carol as a member.
|
||||
if err := admin.AddUser(carolPub, "carol", membership.RoleMember); err != nil {
|
||||
t.Fatalf("admin AddUser: %v", err)
|
||||
}
|
||||
|
||||
// Admin lists: carol present and active.
|
||||
users, err := admin.ListUsers()
|
||||
if err != nil {
|
||||
t.Fatalf("admin ListUsers: %v", err)
|
||||
}
|
||||
row, ok := findUserInfo(users, carolPub)
|
||||
if !ok {
|
||||
t.Fatalf("carol missing from list after add: %+v", users)
|
||||
}
|
||||
if row.Status != membership.StatusActive || row.Role != membership.RoleMember {
|
||||
t.Fatalf("carol row wrong after add: %+v", row)
|
||||
}
|
||||
|
||||
// Re-adding the same key is a conflict surfaced as an error (no silent upsert).
|
||||
if err := admin.AddUser(carolPub, "carol-again", membership.RoleAdmin); err == nil {
|
||||
t.Fatalf("re-adding carol should error (409), got nil")
|
||||
}
|
||||
|
||||
// Admin revokes carol; list shows the status flip (no hard delete).
|
||||
if err := admin.RevokeUser(carolPub); err != nil {
|
||||
t.Fatalf("admin RevokeUser: %v", err)
|
||||
}
|
||||
users, err = admin.ListUsers()
|
||||
if err != nil {
|
||||
t.Fatalf("admin ListUsers after revoke: %v", err)
|
||||
}
|
||||
row, ok = findUserInfo(users, carolPub)
|
||||
if !ok {
|
||||
t.Fatalf("carol vanished after revoke (should be a status flip): %+v", users)
|
||||
}
|
||||
if row.Status != membership.StatusRevoked {
|
||||
t.Fatalf("carol should be revoked, got status %q", row.Status)
|
||||
}
|
||||
|
||||
// A non-admin (member) is denied on every user-management method.
|
||||
if _, err := member.ListUsers(); err == nil {
|
||||
t.Fatalf("non-admin ListUsers should error (403), got nil")
|
||||
}
|
||||
if err := member.AddUser(carolPub, "x", membership.RoleMember); err == nil {
|
||||
t.Fatalf("non-admin AddUser should error (403), got nil")
|
||||
}
|
||||
if err := member.RevokeUser(carolPub); err == nil {
|
||||
t.Fatalf("non-admin RevokeUser should error (403), got nil")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user