450ca01baf
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>
100 lines
3.2 KiB
Go
100 lines
3.2 KiB
Go
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")
|
|
}
|
|
}
|