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>
165 lines
5.8 KiB
Go
165 lines
5.8 KiB
Go
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
|
|
}
|