Files
pad/internal/server/handlers_admin_invitations.go
T
xarmian 0c68bbc73d fix(store): an admin resend revokes and re-creates in one transaction that re-reads the workspace (BUG-3817) (#2193)
* fix(store): an admin resend revokes and re-creates in one transaction that re-reads the workspace (BUG-3817)

handleAdminResendInvitation checked the workspace was live, then revoked the
invitation and created a new one in two separate transactions. A workspace
deleted between the check and the create still got a fresh code and an
email, and a create refused after the revoke (a disabled inviter) left the
invitee with no invitation. ResendInvitationAdmin now does both in one
transaction under the workspace lock, and createInvitationTx, which
CreateInvitation shares, reads the workspace's liveness FOR SHARE, a lock a
soft delete's update conflicts with. Both handlers map the new refusals to
404.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr

* fix(store): a resend revokes only after its user and workspace row locks (BUG-3817)

Codex r1: the resend revoked the old invitation before createInvitationTx
locked the inviter and the workspace. Account deletion locks the user,
soft-deletes their workspaces and then deletes the invitations they sent,
so the two took the rows in opposite orders and deadlocked. The revoke
moves inside createInvitationTx, after those locks. The race tests wait
for the resend's own liveness read, not for any lock wait in the database,
and a new test replays the deletion's order against a resend.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr

* test(store): a resend of an accepted invitation is refused; the disable fixture uses the guarded door (BUG-3817)

The lead's r1 M2 condition: a sequential test that resends an accepted
invitation and asserts ErrInvitationNotPending, with the accepted row and
the membership kept. With the pre-transaction check and the in-transaction
re-read both removed, it stays green on the revoke's affected-row count, and
goes red only once that DELETE stops filtering accepted_at IS NULL.

Rebased onto main past BUG-3826 (#2196), which removed Store.DisableUser:
the refused-resend fixture now disables through DisableUserAndRevokeAccess.

Claude-Session: https://claude.ai/code/session_012QxKpjqXTpU84auY5zZ8FR
2026-10-15 16:19:58 -04:00

171 lines
4.8 KiB
Go

package server
import (
"database/sql"
"errors"
"log/slog"
"net/http"
"github.com/PerpetualSoftware/pad/internal/email"
"github.com/PerpetualSoftware/pad/internal/models"
"github.com/PerpetualSoftware/pad/internal/store"
"github.com/go-chi/chi/v5"
)
// handleAdminListInvitations returns all pending invitations across all workspaces.
// GET /api/v1/admin/invitations?q=search
func (s *Server) handleAdminListInvitations(w http.ResponseWriter, r *http.Request) {
if !requireAdmin(w, r) {
return
}
query := r.URL.Query().Get("q")
invitations, err := s.store.ListPendingInvitationsAdmin(query)
if err != nil {
writeInternalError(w, err)
return
}
if invitations == nil {
invitations = []store.AdminInvitation{}
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"invitations": invitations,
})
}
// handleAdminResendInvitation revokes the old invitation and creates a fresh one
// with a new code, then sends the invitation email.
// POST /api/v1/admin/invitations/{invID}/resend
func (s *Server) handleAdminResendInvitation(w http.ResponseWriter, r *http.Request) {
if !requireAdmin(w, r) {
return
}
invID := chi.URLParam(r, "invID")
old, err := s.store.GetInvitation(invID)
if err != nil {
writeInternalError(w, err)
return
}
if old == nil || old.AcceptedAt != nil {
writeError(w, http.StatusNotFound, "not_found", "Pending invitation not found")
return
}
// An invitation can outlive its workspace (BUG-3104): DeleteAccountAtomic
// removes only the invitations the departing user SENT. Resending one would
// mint a live code for a deleted workspace and email the invitee about it,
// so it answers as not found — the same answer the code lookup now gives.
// GetWorkspaceByID reads only live workspaces.
ws, err := s.store.GetWorkspaceByID(old.WorkspaceID)
if err != nil {
writeInternalError(w, err)
return
}
if ws == nil {
writeError(w, http.StatusNotFound, "not_found", "Pending invitation not found")
return
}
caller := currentUser(r)
inviterID := old.InvitedBy
if caller != nil {
inviterID = caller.ID
}
// The revoke and the new invitation are one transaction that re-reads the
// workspace's liveness under its lock (BUG-3817): a refused create can no
// longer lose the original, and a workspace deleted since the check above
// gets no new code.
inv, err := s.store.ResendInvitationAdmin(invID, inviterID)
if writeAccountDisabledIf(w, err) {
return
}
if errors.Is(err, store.ErrInvitationNotPending) {
writeError(w, http.StatusNotFound, "not_found", "Invitation is no longer pending")
return
}
if errors.Is(err, store.ErrInvitationWorkspaceDeleted) {
writeError(w, http.StatusNotFound, "not_found", "Pending invitation not found")
return
}
if err != nil {
writeInternalError(w, err)
return
}
// Send email
joinURL := ""
if s.baseURL != "" {
joinURL = s.baseURL + "/join/" + inv.Code
}
if s.email != nil && joinURL != "" {
// Respect email opt-out preferences
if optedOut, err := s.store.IsEmailOptedOut(inv.Email); err == nil && optedOut {
writeJSON(w, http.StatusOK, map[string]interface{}{
"ok": true,
"method": "code",
"message": "Invitation recreated but email not sent (recipient opted out)",
})
return
}
inviterName := "An administrator"
wsName := "a workspace"
if caller != nil {
inviterName = caller.Name
}
if ws, err := s.store.GetWorkspaceByID(old.WorkspaceID); err == nil && ws != nil {
wsName = ws.Name
}
unsubURL := email.UnsubscribeURL(s.baseURL, inv.Email, s.unsubscribeSecret())
if err := s.email.SendInvitation(r.Context(), inv.Email, inviterName, wsName, invitationEmailURL(joinURL, inv.Proof), unsubURL); err != nil {
slog.Error("failed to resend invitation email", "error", err, "email", inv.Email)
writeError(w, http.StatusInternalServerError, "email_failed", "Invitation recreated but failed to send email")
return
}
}
s.logAuditEvent(models.ActionMemberInvited, r, auditMeta(map[string]string{
"email": inv.Email,
"role": inv.Role,
"workspace": old.WorkspaceID,
"resend": "true",
}))
method := "code"
if s.email != nil && joinURL != "" {
method = "email"
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"ok": true,
"method": method,
"message": "Invitation resent to " + inv.Email,
})
}
// handleAdminDeleteInvitation revokes a pending invitation.
// DELETE /api/v1/admin/invitations/{invID}
func (s *Server) handleAdminDeleteInvitation(w http.ResponseWriter, r *http.Request) {
if !requireAdmin(w, r) {
return
}
invID := chi.URLParam(r, "invID")
if err := s.store.DeleteInvitationAdmin(invID); err != nil {
if err == sql.ErrNoRows {
writeError(w, http.StatusNotFound, "not_found", "Pending invitation not found")
return
}
writeInternalError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"ok": true,
"message": "Invitation revoked",
})
}