mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-27 06:47:07 +00:00
8c8deeeb89
* test(server): an invite asks an existing user; re-invite replaces; decline by id and by code (BUG-2136, red)
* fix(server): an invite asks an existing user instead of adding them; a re-invite replaces the pending invitation; decline by id and by code (BUG-2136)
* feat(cli,mcp): pad workspace invitations/accept/decline; invite output says pending; ToolSurfaceVersion 0.66 (BUG-2136)
* test(e2e): invitees accept their invitation before the specs use the membership (BUG-2136)
* test(store): concurrent re-invites of one address leave one pending invitation (BUG-2136)
* test(store): an accept that loses to a decline adds no membership (BUG-2136, codex r1, red)
* test(server): an unverified invitee can decline by code, as they can accept (BUG-2136, codex r1, red)
* fix: an accept that loses to a decline or replace adds no membership; decline by code passes the verified-email gate; honest oracle comment (BUG-2136, codex r1)
* fix(server): the verified-email exemption matches exactly /invitations/{code}/accept|decline (BUG-2136, codex r2)
* test(server): drop the plan-limit harness's now-unused newUser (BUG-2136)
154 lines
7.1 KiB
Go
154 lines
7.1 KiB
Go
package server
|
|
|
|
import (
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
)
|
|
|
|
// emailUnverifiedBlocked is the central predicate for PLAN-1933 DR-4's
|
|
// email-verification gate. It reports whether a request carrying `user`
|
|
// must be rejected because this is a Pad Cloud instance and the
|
|
// authenticated user has NOT verified their email address.
|
|
//
|
|
// Two short-circuits keep the gate a no-op everywhere it shouldn't fire:
|
|
//
|
|
// - Self-hosted (`!s.cloudMode`) always returns false. Mandatory
|
|
// email verification is a cloud-only feature (DR-9), so the gate is
|
|
// entirely inert on self-hosted binaries — the middleware, the
|
|
// collab upgrade check, the OAuth-provider checks, and the MCP
|
|
// write gate all collapse to pass-through.
|
|
// - An unauthenticated request (user == nil) returns false. The gate
|
|
// only constrains what a logged-in-but-unverified user may do; it
|
|
// never turns an anonymous request into a 403 (RequireAuth already
|
|
// owns the "must be logged in" decision). Keying on user != nil is
|
|
// also what auto-skips the unauthenticated auth routes — login,
|
|
// register, forgot/reset-password, bootstrap, CLI-session creation —
|
|
// so no fragile URL allowlist is needed for those (DR-4 core rule).
|
|
// It is also why legacy workspace-scoped API tokens with no
|
|
// currentUser (middleware_auth.go) are intentionally NOT gated:
|
|
// currentUser==nil → false. That's acceptable per DR-4 (pre-existing
|
|
// token owners are backfilled verified; unverified users cannot mint
|
|
// tokens because token-create is blocked below; MCP PAT auth already
|
|
// rejects legacy no-user workspace tokens in middleware_mcp_auth.go).
|
|
//
|
|
// Shared by the RequireVerifiedEmail middleware (the /api/v1 method
|
|
// gate), the collab WebSocket upgrade check (a GET the method gate
|
|
// misses), and the OAuth-provider authorize/decide handlers (mounted
|
|
// outside /api/v1). The remote MCP write path uses an equivalent inline
|
|
// predicate wired onto the dispatcher in cmd/pad/main.go.
|
|
func (s *Server) emailUnverifiedBlocked(user *models.User) bool {
|
|
return s.cloudMode && user != nil && !user.IsEmailVerified()
|
|
}
|
|
|
|
// isMutatingMethod reports whether an HTTP method can mutate server
|
|
// state. The verified-email gate only fires on these — reads (GET /
|
|
// HEAD / OPTIONS) stay open for unverified users so they can still
|
|
// browse, poll their session, and see the "verify your email" banner.
|
|
func isMutatingMethod(method string) bool {
|
|
switch method {
|
|
case http.MethodPost, http.MethodPatch, http.MethodPut, http.MethodDelete:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// verifiedEmailExemptPath reports whether a mutating /api/v1 request
|
|
// stays allowed for an authenticated-but-unverified cloud user. These
|
|
// are the narrow, deliberate carve-outs from DR-4:
|
|
//
|
|
// - POST /api/v1/invitations/{code}/accept — the hard carve-out. It
|
|
// lives OUTSIDE /auth/, and blocking it would strand an unverified
|
|
// invitee (they could never join a workspace). Per DR-1, accepting
|
|
// an email-bound invitation VERIFIES the account, so this is also
|
|
// an escape hatch OUT of the unverified state.
|
|
// - The account-lifecycle /auth/ mutations an unverified user must be
|
|
// able to run: logout (end the session), verify-email +
|
|
// resend-verification (the routes that clear the unverified state —
|
|
// they land in Wave 3b; allowlisting them now is harmless because
|
|
// they aren't routed yet), and delete-account (removing your own
|
|
// unverified account is harmless).
|
|
//
|
|
// Everything else — token create/rotate/delete, PATCH /me, 2FA
|
|
// setup/disable, OAuth link/unlink, CLI-session approve, and every
|
|
// workspace/content mutation — is intentionally NOT here, so it gets
|
|
// the 403. The mutating auth routes that are unauthenticated (login,
|
|
// register, forgot/reset-password, bootstrap, 2fa/login-verify,
|
|
// oauth-login, CLI-session create) never reach this check: their
|
|
// callers have no currentUser, so emailUnverifiedBlocked already
|
|
// returned false.
|
|
func verifiedEmailExemptPath(path string) bool {
|
|
// Invitation accept and decline (BUG-2136): the two answers to one
|
|
// invitation, matched structurally so any {code} passes, while
|
|
// /invitations/{code}/preview (GET, already public) and any other
|
|
// /invitations subpath are unaffected.
|
|
// Exactly two segments after the prefix (codex r2), so no nested path
|
|
// that happens to end in /accept or /decline is exempted.
|
|
if rest, ok := strings.CutPrefix(path, "/api/v1/invitations/"); ok {
|
|
if code, action, found := strings.Cut(rest, "/"); found && code != "" &&
|
|
(action == "accept" || action == "decline") {
|
|
return true
|
|
}
|
|
}
|
|
switch path {
|
|
case "/api/v1/auth/logout",
|
|
"/api/v1/auth/verify-email",
|
|
"/api/v1/auth/verify-email/claim",
|
|
"/api/v1/auth/resend-verification",
|
|
"/api/v1/auth/delete-account":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// RequireVerifiedEmail is the /api/v1 method-gated enforcement point for
|
|
// DR-4. Mounted immediately after RequireAuth in setupRouter, it rejects
|
|
// mutating requests (POST/PATCH/PUT/DELETE) from an authenticated cloud
|
|
// user whose email is unverified, returning 403 email_not_verified.
|
|
//
|
|
// It deliberately does NOT inherit CSRFProtect's / RequireAuth's blanket
|
|
// `/api/v1/auth/*` exemption — those exemptions exist so unauthenticated
|
|
// callers can reach login/register, but an authenticated-but-unverified
|
|
// user hitting POST /api/v1/auth/tokens must still be blocked. This
|
|
// middleware makes its own decision: emailUnverifiedBlocked auto-skips
|
|
// the unauthenticated routes (currentUser==nil), and
|
|
// verifiedEmailExemptPath allows the handful of authenticated auth
|
|
// mutations an unverified user legitimately needs.
|
|
//
|
|
// The method gate is necessary but NOT sufficient — several mutation
|
|
// surfaces are reached without a mutating /api/v1 method:
|
|
//
|
|
// - the collab WebSocket upgrade is a GET (handled in
|
|
// authorizeCollabAccess),
|
|
// - the OAuth-provider authorize/decide endpoints are mounted outside
|
|
// /api/v1 (handled in handlers_oauth.go),
|
|
// - the remote MCP write path dispatches in-process (handled by the
|
|
// dispatcher's RequireVerifiedEmail hook, wired in cmd/pad/main.go).
|
|
//
|
|
// Each of those is gated separately; see DR-4's systematic perimeter
|
|
// audit and the tests in middleware_verified_email_test.go.
|
|
func (s *Server) RequireVerifiedEmail(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if !s.emailUnverifiedBlocked(currentUser(r)) {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
if !isMutatingMethod(r.Method) || verifiedEmailExemptPath(r.URL.Path) {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
writeEmailNotVerified(w)
|
|
})
|
|
}
|
|
|
|
// writeEmailNotVerified is the single 403 writer shared by every
|
|
// verified-email enforcement point so the code/message stay identical
|
|
// across perimeters (the middleware, the collab upgrade, the OAuth
|
|
// handlers). Clients branch on the stable `email_not_verified` code.
|
|
func writeEmailNotVerified(w http.ResponseWriter) {
|
|
writeError(w, http.StatusForbidden, "email_not_verified",
|
|
"Verify your email address to perform this action. Check your inbox for the verification link or request a new one.")
|
|
}
|