From 18987bbd2f4d06adb74d8ff8a7dc2a5a8a6d08c3 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sun, 7 Jun 2026 22:14:44 +0200 Subject: [PATCH] feat(membership): invite, register and hard-delete HTTP endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose the account-creation surface over the signed control plane: - POST /invites, GET /invites (pending only), DELETE /invites/{token}, DELETE /users/{signpub}: all admin-only via requireAdmin (default-deny by role). - POST /register: the wallet-model join path. It is the ONLY allowlist-mutating route exempt from the admin signature, because the registering identity is not yet in the allowlist and cannot sign — authorization is the bearer invite token. It validates both public keys (sign_pub Ed25519, kex_pub X25519, 64-hex) BEFORE spending the token, fixes handle/role from the invite (no client escalation), and maps state errors to precise codes (unknown 404, used 409, expired 410, already-registered 409). Split isRateExempt (only /healthz) from isAuthExempt (/healthz + POST /register) so /register skips the admin-signature middleware but stays per-IP rate limited. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/membership/server.go | 240 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 233 insertions(+), 7 deletions(-) diff --git a/pkg/membership/server.go b/pkg/membership/server.go index 198b0efb..79d1384d 100644 --- a/pkg/membership/server.go +++ b/pkg/membership/server.go @@ -144,9 +144,11 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { now := time.Now() // Per-IP rate limit runs first, ahead of auth and body reads, so a flood is - // shed at the cheapest possible point. The health probe is exempt so liveness - // checks are never throttled. - if !isAuthExempt(r) && !s.limiter.allow(clientIP(r), now) { + // shed at the cheapest possible point. ONLY the health probe is exempt so + // liveness checks are never throttled — note this is isRateExempt, NOT + // isAuthExempt: POST /register is auth-exempt (no admin signature) but stays + // rate-limited, since it is the one un-signed path that mutates the allowlist. + if !isRateExempt(r) && !s.limiter.allow(clientIP(r), now) { writeErr(w, http.StatusTooManyRequests, "rate limit exceeded") return } @@ -308,13 +310,29 @@ func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) (string, b 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. -func isAuthExempt(r *http.Request) bool { +// isRateExempt lists requests that bypass the per-IP rate limiter. Only the +// health probe qualifies: a load balancer / systemd / smoke check polls it and +// must never be throttled. Everything else — including POST /register — is rate +// limited. +func isRateExempt(r *http.Request) bool { return r.Method == http.MethodGet && r.URL.Path == "/healthz" } +// isAuthExempt lists requests that bypass control-plane signature auth even under +// enforce. Two qualify: +// - GET /healthz: carries no data, needed before any identity exists. +// - POST /register: the wallet-model join path. The registering identity is not +// yet in the allowlist, so it CANNOT produce an accepted admin signature; +// authorization is the single-use bearer invite token, validated inside the +// handler (ConsumeInvite). It stays rate-limited (see isRateExempt) and +// strictly validates the hex keys before spending the token. +func isAuthExempt(r *http.Request) bool { + if r.Method == http.MethodGet && r.URL.Path == "/healthz" { + return true + } + return r.Method == http.MethodPost && r.URL.Path == "/register" +} + func (s *Server) routes() { s.mux.HandleFunc("GET /healthz", s.handleHealth) s.mux.HandleFunc("POST /rooms", s.handleCreateRoom) @@ -333,6 +351,16 @@ func (s *Server) routes() { s.mux.HandleFunc("GET /users", s.handleListUsers) s.mux.HandleFunc("POST /users", s.handleAddUser) s.mux.HandleFunc("POST /users/{signpub}/revoke", s.handleRevokeUser) + // Hard-delete (purge) a user — distinct from revoke (status flip). Admin-only. + s.mux.HandleFunc("DELETE /users/{signpub}", s.handleDeleteUser) + // Invites — the wallet-model account-creation path. The admin mints a + // single-use link (POST /invites, admin-only); the new user's client redeems + // it without an admin signature (POST /register, token-authorized). Listing + // and cancelling a pending invite are admin-only. + s.mux.HandleFunc("POST /invites", s.handleCreateInvite) + s.mux.HandleFunc("GET /invites", s.handleListInvites) + s.mux.HandleFunc("DELETE /invites/{token}", s.handleCancelInvite) + s.mux.HandleFunc("POST /register", s.handleRegister) } // ---- wire types ----------------------------------------------------------- @@ -431,6 +459,46 @@ type addUserReq struct { Role string `json:"role"` } +// createInviteReq is the POST /invites body (admin-only): the handle and role the +// future user will receive (fixed here, NOT chosen by the registering client) and +// an optional TTL in seconds (non-positive uses the 7-day default). +type createInviteReq struct { + Handle string `json:"handle"` + Role string `json:"role"` + TTLSecs int `json:"ttl_secs"` +} + +// createInviteResp is the POST /invites reply: the bearer token to put in the +// join link and its absolute expiry. The token is shown ONCE here; the admin +// copies the link immediately. +type createInviteResp struct { + Token string `json:"token"` + ExpiresAt string `json:"expires_at"` +} + +// inviteJSON is the wire representation of a pending invite on GET /invites. It +// omits the audit fields (used_*) because the listing is of pending invites only; +// used_at is carried so a client can render "expires in N". +type inviteJSON struct { + Token string `json:"token"` + Handle string `json:"handle"` + Role string `json:"role"` + ExpiresAt string `json:"expires_at"` + Used bool `json:"used"` + CreatedAt string `json:"created_at"` +} + +// registerReq is the POST /register body. It is the ONLY allowlist-mutating +// request that carries no admin signature: the bearer Token authorizes it. The +// client supplies its freshly-generated public keys (sign_pub = Ed25519 identity, +// kex_pub = X25519 key-exchange), both 64-hex. The handle and role come from the +// invite, never from this body — the client cannot escalate. +type registerReq struct { + Token string `json:"token"` + SignPub string `json:"sign_pub"` + KexPub string `json:"kex_pub"` +} + // ---- helpers -------------------------------------------------------------- func writeJSON(w http.ResponseWriter, code int, v any) { @@ -840,3 +908,161 @@ func (s *Server) handleRevokeUser(w http.ResponseWriter, r *http.Request) { } writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"}) } + +// handleDeleteUser hard-deletes a bus user by signing key — the purge that the +// admin panel's "Eliminar" (permanent) action maps to, distinct from revoke's +// status flip. The row is removed entirely (no audit trail kept); use revoke when +// an auditable record must remain. Deleting an unknown key is a 404. Admin-only. +// +// Security note: like revoke, this does NOT special-case the last admin — an +// admin can delete the final admin and lock the HTTP user-management surface. The +// recovery seam is the local `membershipd user add` CLI (which re-seeds an admin +// directly against the store), the same chicken-egg breaker that seeds the first +// admin. +func (s *Server) handleDeleteUser(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.DeleteUser(signPub); err != nil { + if errors.Is(err, ErrNotFound) { + writeErr(w, http.StatusNotFound, "no user with that key") + return + } + writeServerErr(w, r, http.StatusInternalServerError, "internal error", err) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) +} + +// ---- invite handlers ------------------------------------------------------ + +// handleCreateInvite mints a single-use registration invite. The handle and role +// are fixed here by the admin; the role is validated (admin|member, empty -> +// member) so an unknown role is a clean 400 rather than an opaque 500. The reply +// carries the bearer token and its expiry — the admin turns the token into the +// join link. Admin-only. +func (s *Server) handleCreateInvite(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requireAdmin(w, r); !ok { + return + } + var req createInviteReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, http.StatusBadRequest, "bad json: "+err.Error()) + return + } + if req.Handle == "" { + writeErr(w, http.StatusBadRequest, "handle required") + return + } + if req.Role != "" && req.Role != RoleAdmin && req.Role != RoleMember { + writeErr(w, http.StatusBadRequest, + fmt.Sprintf("invalid role %q (want %q or %q)", req.Role, RoleAdmin, RoleMember)) + return + } + inv, err := s.store.CreateInvite(req.Handle, req.Role, req.TTLSecs) + if err != nil { + writeServerErr(w, r, http.StatusInternalServerError, "internal error", err) + return + } + writeJSON(w, http.StatusCreated, createInviteResp{Token: inv.Token, ExpiresAt: inv.ExpiresAt}) +} + +// handleListInvites returns the PENDING invites (not yet used and not expired), so +// the admin panel shows only live links worth copying. Consumed/expired invites +// are filtered out here rather than at the store, which exposes the full set for +// other callers. Admin-only. +func (s *Server) handleListInvites(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requireAdmin(w, r); !ok { + return + } + invites, err := s.store.ListInvites() + if err != nil { + writeServerErr(w, r, http.StatusInternalServerError, "internal error", err) + return + } + out := make([]inviteJSON, 0, len(invites)) + for _, inv := range invites { + if inv.Used || inviteIsExpired(inv.ExpiresAt) { + continue // pending only + } + out = append(out, inviteJSON{ + Token: inv.Token, + Handle: inv.Handle, + Role: inv.Role, + ExpiresAt: inv.ExpiresAt, + Used: inv.Used, + CreatedAt: inv.CreatedAt, + }) + } + writeJSON(w, http.StatusOK, out) +} + +// handleCancelInvite cancels (hard-deletes) a pending invite, so an admin can +// revoke a link before it is redeemed. Cancelling an unknown token is a 404. +// Admin-only. +func (s *Server) handleCancelInvite(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requireAdmin(w, r); !ok { + return + } + token := r.PathValue("token") + if token == "" { + writeErr(w, http.StatusBadRequest, "token required") + return + } + if err := s.store.CancelInvite(token); err != nil { + if errors.Is(err, ErrNotFound) { + writeErr(w, http.StatusNotFound, "no such invite") + return + } + writeServerErr(w, r, http.StatusInternalServerError, "internal error", err) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "cancelled"}) +} + +// handleRegister redeems an invite: the wallet-model join path. It is auth-exempt +// (no admin signature; see isAuthExempt) but rate-limited and strictly validated. +// The client presents the single-use token plus its freshly-generated public keys +// (sign_pub Ed25519, kex_pub X25519). Both keys are validated as 64-hex BEFORE the +// token is spent, the handle and role come from the invite (never this body), and +// ConsumeInvite enforces single-use atomically. Errors map to precise codes so a +// client can tell "unknown" from "used" from "expired". +func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { + var req registerReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, http.StatusBadRequest, "bad json: "+err.Error()) + return + } + if req.Token == "" { + writeErr(w, http.StatusBadRequest, "token required") + return + } + if err := ValidateSignPubHex(req.SignPub); err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + if err := ValidateKexPubHex(req.KexPub); err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + err := s.store.ConsumeInvite(req.Token, req.SignPub, req.KexPub) + switch { + case err == nil: + writeJSON(w, http.StatusCreated, map[string]string{"status": "registered"}) + case errors.Is(err, ErrNotFound): + writeErr(w, http.StatusNotFound, "invalid or unknown invite token") + case errors.Is(err, ErrInviteUsed): + writeErr(w, http.StatusConflict, "invite already used") + case errors.Is(err, ErrInviteExpired): + writeErr(w, http.StatusGone, "invite expired") + case errors.Is(err, ErrUserExists): + writeErr(w, http.StatusConflict, "identity already registered") + default: + writeServerErr(w, r, http.StatusInternalServerError, "internal error", err) + } +}