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:
+173
-7
@@ -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"})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user