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:
@@ -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