feat(membership): invite, register and hard-delete HTTP endpoints

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 22:14:44 +02:00
parent d64b0c052d
commit 18987bbd2f
+233 -7
View File
@@ -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)
}
}