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:
+233
-7
@@ -144,9 +144,11 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
// Per-IP rate limit runs first, ahead of auth and body reads, so a flood is
|
// 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
|
// shed at the cheapest possible point. ONLY the health probe is exempt so
|
||||||
// checks are never throttled.
|
// liveness checks are never throttled — note this is isRateExempt, NOT
|
||||||
if !isAuthExempt(r) && !s.limiter.allow(clientIP(r), now) {
|
// 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")
|
writeErr(w, http.StatusTooManyRequests, "rate limit exceeded")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -308,13 +310,29 @@ func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) (string, b
|
|||||||
return pubHex, true
|
return pubHex, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// isAuthExempt lists requests that bypass control-plane auth even under enforce.
|
// isRateExempt lists requests that bypass the per-IP rate limiter. Only the
|
||||||
// Only the unauthenticated health probe qualifies: it carries no data and is
|
// health probe qualifies: a load balancer / systemd / smoke check polls it and
|
||||||
// needed by load balancers / smoke checks / systemd before any identity exists.
|
// must never be throttled. Everything else — including POST /register — is rate
|
||||||
func isAuthExempt(r *http.Request) bool {
|
// limited.
|
||||||
|
func isRateExempt(r *http.Request) bool {
|
||||||
return r.Method == http.MethodGet && r.URL.Path == "/healthz"
|
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() {
|
func (s *Server) routes() {
|
||||||
s.mux.HandleFunc("GET /healthz", s.handleHealth)
|
s.mux.HandleFunc("GET /healthz", s.handleHealth)
|
||||||
s.mux.HandleFunc("POST /rooms", s.handleCreateRoom)
|
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("GET /users", s.handleListUsers)
|
||||||
s.mux.HandleFunc("POST /users", s.handleAddUser)
|
s.mux.HandleFunc("POST /users", s.handleAddUser)
|
||||||
s.mux.HandleFunc("POST /users/{signpub}/revoke", s.handleRevokeUser)
|
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 -----------------------------------------------------------
|
// ---- wire types -----------------------------------------------------------
|
||||||
@@ -431,6 +459,46 @@ type addUserReq struct {
|
|||||||
Role string `json:"role"`
|
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 --------------------------------------------------------------
|
// ---- helpers --------------------------------------------------------------
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, code int, v any) {
|
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"})
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user