diff --git a/cmd/membershipd/users_cli.go b/cmd/membershipd/users_cli.go index b44ea1b..156a7bd 100644 --- a/cmd/membershipd/users_cli.go +++ b/cmd/membershipd/users_cli.go @@ -1,7 +1,6 @@ package main import ( - "encoding/hex" "errors" "flag" "fmt" @@ -90,16 +89,10 @@ func openStore(path string) membership.Store { // validateSignPubHex ensures the key is exactly a 32-byte Ed25519 public key in // hex (64 hex chars). Catching this here turns a silent "authorized nobody" into -// an explicit error at seed time. +// an explicit error at seed time. It delegates to membership.ValidateSignPubHex +// so the CLI and the HTTP user-management handlers share one rule. func validateSignPubHex(signPub string) error { - b, err := hex.DecodeString(signPub) - if err != nil { - return fmt.Errorf("sign-pub is not valid hex: %w", err) - } - if len(b) != 32 { - return fmt.Errorf("sign-pub must be a 32-byte Ed25519 public key (64 hex chars), got %d bytes", len(b)) - } - return nil + return membership.ValidateSignPubHex(signPub) } // kvFlags holds the connection flags shared by the --store kv path of the user diff --git a/pkg/client/client.go b/pkg/client/client.go index 005df6a..80a4340 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -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) diff --git a/pkg/client/users_test.go b/pkg/client/users_test.go new file mode 100644 index 0000000..0adc873 --- /dev/null +++ b/pkg/client/users_test.go @@ -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") + } +} diff --git a/pkg/membership/server.go b/pkg/membership/server.go index 51a0377..198b0ef 100644 --- a/pkg/membership/server.go +++ b/pkg/membership/server.go @@ -213,9 +213,12 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { writeErr(w, http.StatusUnauthorized, "unauthorized: "+err.Error()) return } - // Carry the authenticated signer's endpoint into the handler so room handlers - // can authorize by membership (audit H3). Only set on a verified identity. - s.mux.ServeHTTP(w, r.WithContext(withSigner(r.Context(), res.endpoint))) + // Carry the authenticated signer's endpoint AND signing key into the handler. + // Room handlers authorize by membership via the endpoint (audit H3); the + // user-management handlers authorize by role via the signing key (the endpoint + // id is a one-way hash of the key, so it cannot be reversed to look the signer + // up in the user allowlist). Both are set only on a verified identity. + s.mux.ServeHTTP(w, r.WithContext(withSigner(r.Context(), res.endpoint, res.pubHex))) } // isBodyTooLarge reports whether err is the sentinel returned by MaxBytesReader @@ -229,11 +232,19 @@ func isBodyTooLarge(err error) bool { // values cannot collide with keys set by other packages. type ctxKey int -const ctxSignerEndpoint ctxKey = iota +const ( + ctxSignerEndpoint ctxKey = iota + ctxSignerPub +) -// withSigner returns a context carrying the authenticated signer's endpoint id. -func withSigner(ctx context.Context, endpoint string) context.Context { - return context.WithValue(ctx, ctxSignerEndpoint, endpoint) +// withSigner returns a context carrying the authenticated signer's endpoint id +// and signing public key (lowercase hex). The endpoint authorizes room +// membership; the signing key authorizes user-management by role, because the +// endpoint id is a one-way hash of the key (base64url(sha256(signPub))) and so +// cannot be reversed to look the signer up in the user allowlist. +func withSigner(ctx context.Context, endpoint, pubHex string) context.Context { + ctx = context.WithValue(ctx, ctxSignerEndpoint, endpoint) + return context.WithValue(ctx, ctxSignerPub, pubHex) } // signerEndpoint returns the authenticated signer's endpoint id and whether one @@ -245,6 +256,16 @@ func signerEndpoint(r *http.Request) (string, bool) { return v, ok && v != "" } +// signerPubHex returns the authenticated signer's signing public key (lowercase +// hex) and whether one is present. Like signerEndpoint it is absent under +// AuthOff and on a soft-mode pass-through; the user-management handlers treat +// that absence as "no admin identity" and deny (default-deny), since a +// privilege-granting operation must never run without a verified admin. +func signerPubHex(r *http.Request) (string, bool) { + v, ok := r.Context().Value(ctxSignerPub).(string) + return v, ok && v != "" +} + // requireMember authorizes a room request by membership (audit H3): it returns // the signer endpoint and true when the request may proceed, or writes 403 and // returns false when an authenticated signer is not a member of roomID. When no @@ -262,6 +283,31 @@ func (s *Server) requireMember(w http.ResponseWriter, r *http.Request, roomID st return signer, true } +// requireAdmin authorizes a user-management request: it returns the signer's +// signing-key hex and true ONLY when the authenticated signer is a user with +// role admin and active status; otherwise it writes 403 and returns false. +// +// Default-deny, with no dev relaxation: unlike requireMember (which allows a +// request when no authenticated signer is present, preserving AuthOff/dev +// behavior for room reads), this denies whenever the signer is absent or is not +// a verified active admin. The user-management endpoints grant and revoke bus +// access, so they must never be reachable without a verified admin identity — +// the store is consulted on every call so a just-revoked admin is denied +// immediately, and any store error fails closed. +func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) (string, bool) { + pubHex, ok := signerPubHex(r) + if !ok { + writeErr(w, http.StatusForbidden, "forbidden: admin role required") + return "", false + } + u, err := s.store.GetUser(pubHex) + if err != nil || u.Role != RoleAdmin || u.Status != StatusActive { + writeErr(w, http.StatusForbidden, "forbidden: admin role required") + return "", false + } + return pubHex, true +} + // isAuthExempt lists requests that bypass control-plane auth even under enforce. // Only the unauthenticated health probe qualifies: it carries no data and is // needed by load balancers / smoke checks / systemd before any identity exists. @@ -280,6 +326,13 @@ func (s *Server) routes() { s.mux.HandleFunc("GET /rooms/{id}", s.handleGetRoom) s.mux.HandleFunc("POST /blobs", s.handlePutBlob) s.mux.HandleFunc("GET /blobs/{hash}", s.handleGetBlob) + // User-management (admin-only) — the HTTP-signed equivalent of the local + // `membershipd user` CLI, so the admin panel manages the bus allowlist by + // signing as an admin instead of needing direct store/KV access. All three + // pass through requireAdmin; they hit the same store the room handlers do. + s.mux.HandleFunc("GET /users", s.handleListUsers) + s.mux.HandleFunc("POST /users", s.handleAddUser) + s.mux.HandleFunc("POST /users/{signpub}/revoke", s.handleRevokeUser) } // ---- wire types ----------------------------------------------------------- @@ -357,6 +410,27 @@ type blobResp struct { Hash string `json:"hash"` } +// userJSON is the wire representation of a bus user on the admin endpoints. It +// carries the full record the panel needs to render the allowlist, including +// status (so revoked users are visible) and the timestamps. revoked_at is +// omitted for an active user. +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: the new user's Ed25519 signing key +// (64-hex), human handle, and role. role is optional and defaults to member. +type addUserReq struct { + SignPub string `json:"sign_pub"` + Handle string `json:"handle"` + Role string `json:"role"` +} + // ---- helpers -------------------------------------------------------------- func writeJSON(w http.ResponseWriter, code int, v any) { @@ -674,3 +748,95 @@ func (s *Server) handleGetBlob(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write(data) } + +// ---- user-management handlers (admin-only) -------------------------------- + +// handleListUsers returns the full bus allowlist, including revoked users, so an +// admin sees the complete picture (a revoked identity stays auditable). Admin-only. +func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requireAdmin(w, r); !ok { + return + } + users, err := s.store.ListUsers() + if err != nil { + writeServerErr(w, r, http.StatusInternalServerError, "internal error", err) + return + } + out := make([]userJSON, 0, len(users)) + for _, u := range users { + out = append(out, userJSON{ + SignPub: u.SignPub, + Handle: u.Handle, + Role: u.Role, + Status: u.Status, + CreatedAt: u.CreatedAt, + RevokedAt: u.RevokedAt, + }) + } + writeJSON(w, http.StatusOK, out) +} + +// handleAddUser registers a new bus user from an admin-supplied Ed25519 signing +// key. It mirrors the `membershipd user add` CLI: the key must be 64-hex, the +// role must be admin or member (empty defaults to member), and re-adding an +// already-registered key is a 409 that leaves the existing row untouched — no +// silent upsert that could flip a role or clobber status. Admin-only. +func (s *Server) handleAddUser(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requireAdmin(w, r); !ok { + return + } + var req addUserReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, http.StatusBadRequest, "bad json: "+err.Error()) + return + } + if req.SignPub == "" || req.Handle == "" { + writeErr(w, http.StatusBadRequest, "sign_pub and handle required") + return + } + if err := ValidateSignPubHex(req.SignPub); err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + role := req.Role + if role == "" { + role = RoleMember + } + if role != RoleAdmin && role != RoleMember { + writeErr(w, http.StatusBadRequest, + fmt.Sprintf("invalid role %q (want %q or %q)", role, RoleAdmin, RoleMember)) + return + } + if err := s.store.AddUser(req.SignPub, req.Handle, role); err != nil { + if errors.Is(err, ErrUserExists) { + // Idempotency contract (mirrors the CLI): re-adding a key is an explicit, + // non-destructive conflict. To replace a user, revoke then add again. + writeErr(w, http.StatusConflict, + "user already registered (unchanged); revoke it first to replace") + return + } + writeServerErr(w, r, http.StatusInternalServerError, "internal error", err) + return + } + writeJSON(w, http.StatusCreated, map[string]string{"status": "added"}) +} + +// handleRevokeUser revokes a bus user by signing key. Revocation is a status +// flip (no hard delete) so the identity stays auditable and IsAuthorized denies +// it on both planes immediately. Revoking an unknown or already-revoked key is a +// 404. Admin-only. +func (s *Server) handleRevokeUser(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requireAdmin(w, r); !ok { + return + } + signPub := r.PathValue("signpub") + if err := ValidateSignPubHex(signPub); err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + if err := s.store.RevokeUser(signPub); err != nil { + writeServerErr(w, r, http.StatusNotFound, "no active user with that key", err) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"}) +} diff --git a/pkg/membership/users.go b/pkg/membership/users.go index 3a65597..741194e 100644 --- a/pkg/membership/users.go +++ b/pkg/membership/users.go @@ -2,6 +2,7 @@ package membership import ( "database/sql" + "encoding/hex" "errors" "fmt" "strings" @@ -35,6 +36,23 @@ type User struct { RevokedAt string // empty unless revoked } +// ValidateSignPubHex ensures signPub is exactly a 32-byte Ed25519 public key in +// hex (64 hex chars). It is the single source of truth for that check, shared by +// the local admin CLI (which validates before seeding the first admin) and the +// HTTP user-management handlers (which validate an admin-supplied key before it +// reaches the store). Catching a malformed key here turns a silent "authorized +// nobody" into an explicit error at the boundary. +func ValidateSignPubHex(signPub string) error { + b, err := hex.DecodeString(signPub) + if err != nil { + return fmt.Errorf("sign-pub is not valid hex: %w", err) + } + if len(b) != 32 { + return fmt.Errorf("sign-pub must be a 32-byte Ed25519 public key (64 hex chars), got %d bytes", len(b)) + } + return nil +} + // normalizeSignPub lowercases the hex key so lookups are case-insensitive: the // primary key is stored lowercase and every query normalizes its input the same // way, so a caller passing uppercase hex still matches. diff --git a/pkg/membership/users_http_test.go b/pkg/membership/users_http_test.go new file mode 100644 index 0000000..58a5f6f --- /dev/null +++ b/pkg/membership/users_http_test.go @@ -0,0 +1,164 @@ +package membership + +import ( + "encoding/hex" + "encoding/json" + "net/http" + "testing" + "time" + + cs "fn-registry/functions/cybersecurity" +) + +// signedJSON is signedReq for a JSON body: it marshals v and signs the request +// as id with a distinct nonce. It returns the response status and body, reusing +// the auth_test harness so these tests exercise the real signed wire contract. +func signedJSON(t *testing.T, h *authHarness, method, path string, v any, id cs.Identity, n int) (int, string) { + t.Helper() + var body []byte + if v != nil { + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal body: %v", err) + } + body = b + } + return do(t, signedReq(t, h.ts.URL, method, path, body, id, time.Now().Unix(), nonceN(n))) +} + +// TestUsersHTTP_NonAdminForbidden is the security spine: a REGISTERED but +// non-admin signer (bob, role member) is denied on every user-management +// endpoint. His signature clears auth (he is in the allowlist), so each request +// reaches the handler, where requireAdmin returns 403 — default-deny by role. +func TestUsersHTTP_NonAdminForbidden(t *testing.T) { + h := newAuthHarness(t, AuthEnforce) + + bob, _ := cs.GenerateIdentity() + register(t, h, bob, "bob") // role member (see register in authz_test.go) + bobPub := hex.EncodeToString(bob.SignPub) + + victim, _ := cs.GenerateIdentity() + victimPub := hex.EncodeToString(victim.SignPub) + + checks := []struct { + name string + method string + path string + body any + }{ + {"list users", "GET", "/users", nil}, + {"add user", "POST", "/users", addUserReq{SignPub: victimPub, Handle: "mallory", Role: RoleMember}}, + {"revoke user", "POST", "/users/" + bobPub + "/revoke", nil}, + } + for i, c := range checks { + code, body := signedJSON(t, h, c.method, c.path, c.body, bob, i+1) + if code != http.StatusForbidden { + t.Fatalf("non-admin %s should be 403, got %d (%s)", c.name, code, body) + } + } +} + +// TestUsersHTTP_AdminRoundtrip exercises the golden path end to end: alice (the +// seeded admin) adds carol, sees her in the list as active, revokes her, then +// sees her status flip to revoked (no hard delete — she stays in the list). +func TestUsersHTTP_AdminRoundtrip(t *testing.T) { + h := newAuthHarness(t, AuthEnforce) + + carol, _ := cs.GenerateIdentity() + carolPub := hex.EncodeToString(carol.SignPub) + + // Add carol as a member. + if code, body := signedJSON(t, h, "POST", "/users", + addUserReq{SignPub: carolPub, Handle: "carol", Role: RoleMember}, h.alice, 1); code != http.StatusCreated { + t.Fatalf("admin add carol should be 201, got %d (%s)", code, body) + } + + // List: carol present and active; alice (the seed admin) also present. + users := listUsers(t, h, 2) + carolRow, ok := findUser(users, carolPub) + if !ok { + t.Fatalf("carol missing from list after add: %+v", users) + } + if carolRow.Status != StatusActive || carolRow.Role != RoleMember || carolRow.Handle != "carol" { + t.Fatalf("carol row wrong after add: %+v", carolRow) + } + if _, ok := findUser(users, h.alicePub); !ok { + t.Fatalf("seeded admin alice missing from list: %+v", users) + } + + // Revoke carol. + if code, body := signedJSON(t, h, "POST", "/users/"+carolPub+"/revoke", nil, h.alice, 3); code != http.StatusOK { + t.Fatalf("admin revoke carol should be 200, got %d (%s)", code, body) + } + + // List again: carol still present, now revoked (status flip, not delete). + users = listUsers(t, h, 4) + carolRow, ok = findUser(users, carolPub) + if !ok { + t.Fatalf("carol vanished from list after revoke (should be a status flip): %+v", users) + } + if carolRow.Status != StatusRevoked { + t.Fatalf("carol should be revoked, got status %q", carolRow.Status) + } +} + +// TestUsersHTTP_Validation covers the input-validation contract: a malformed hex +// key is 400, an unknown role is 400, and re-adding an already-registered key is +// 409 (the existing row is left untouched — no silent upsert). +func TestUsersHTTP_Validation(t *testing.T) { + h := newAuthHarness(t, AuthEnforce) + + good, _ := cs.GenerateIdentity() + goodPub := hex.EncodeToString(good.SignPub) + + // Invalid hex (too short) -> 400. + if code, body := signedJSON(t, h, "POST", "/users", + addUserReq{SignPub: "abcd", Handle: "shorty", Role: RoleMember}, h.alice, 1); code != http.StatusBadRequest { + t.Fatalf("malformed sign_pub should be 400, got %d (%s)", code, body) + } + + // Invalid role -> 400. + if code, body := signedJSON(t, h, "POST", "/users", + addUserReq{SignPub: goodPub, Handle: "weirdrole", Role: "superuser"}, h.alice, 2); code != http.StatusBadRequest { + t.Fatalf("invalid role should be 400, got %d (%s)", code, body) + } + + // Re-adding the seeded admin's own key -> 409 (idempotency, no overwrite). + if code, body := signedJSON(t, h, "POST", "/users", + addUserReq{SignPub: h.alicePub, Handle: "alice-again", Role: RoleMember}, h.alice, 3); code != http.StatusConflict { + t.Fatalf("re-adding an existing key should be 409, got %d (%s)", code, body) + } + // And the existing row is untouched: alice is still an active admin. + u, err := h.store.GetUser(h.alicePub) + if err != nil { + t.Fatalf("get alice after conflicting re-add: %v", err) + } + if u.Role != RoleAdmin || u.Status != StatusActive || u.Handle != "alice" { + t.Fatalf("conflicting re-add mutated the existing row: %+v", u) + } +} + +// listUsers signs a GET /users as alice and decodes the response. +func listUsers(t *testing.T, h *authHarness, n int) []userJSON { + t.Helper() + code, body := signedJSON(t, h, "GET", "/users", nil, h.alice, n) + if code != http.StatusOK { + t.Fatalf("admin list users should be 200, got %d (%s)", code, body) + } + var users []userJSON + if err := json.Unmarshal([]byte(body), &users); err != nil { + t.Fatalf("decode users: %v (%s)", err, body) + } + return users +} + +// findUser returns the row with the given signing key (case-insensitive). +func findUser(users []userJSON, signPub string) (userJSON, bool) { + want := normalizeSignPub(signPub) + for _, u := range users { + if normalizeSignPub(u.SignPub) == want { + return u, true + } + } + return userJSON{}, false +}