- tip gate: CreateTipPayment saved-card 2FA gate now has scaTokenizedSavedCard skip matching every other charge surface (booking, terminal, gift-card); isSCATokenizeResultShape escape added to tip SAVE gate - webhook: align UPDATE clears VAT fields before re-apply (matches sweep rescue); 503 unknown-event tracking with 24h timeout notification via square_webhook_events table - cash-tip: cashChargeBasePence no longer restores campaign or subtracts loyalty — overcharge and tip shortfall fixed; 2FA dead code remnants removed from gift-card buy flow; TwoFactorCodeInput help text deconfused; refund pre-fill unit mismatch fixed (pounds vs pence); SCA buyer names split from full_name; passwordless delete UI accepts empty password - lockout: successful current-password clears shared failed_attempts/locked_until (victim can recover from login lockout via password change); passwordless delete condition changed to require 2FA only in enforced env - erasure: stale-guest batch erasure persists Square card/customer targets to durable outbox before NULLing them (crash-safe); S3 deletion retry capped at 10 attempts with admin notification; S3_PROFILE_PICS_BUCKET startup check added - env parsing: IsExplicitDevOrMockEnv and Square HTTP client base-URL switch now normalize (ToLower+TrimSpace) for consistency - auth: change-password/delete-account get per-user rate limiters (10/min); consume param dead code suppressed with TODO - frontend: 2FA/SCA dead code removed from gift-card buy flow, TwoFactorCodeInput help text fixed, refund pre-fill unit mismatch fixed, buyer names populated from full_name Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
810 lines
36 KiB
Go
810 lines
36 KiB
Go
package user
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"sync"
|
|
"time"
|
|
|
|
"crussell/auth"
|
|
"crussell/clock"
|
|
"crussell/db"
|
|
"crussell/handlers/payments"
|
|
"crussell/internal/adminnotify"
|
|
"crussell/internal/s3"
|
|
"crussell/internal/square"
|
|
"crussell/internal/twofa"
|
|
"crussell/mw"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
// --- Square erasure retry + alerting + durable outbox (GDPR H3 / A1) ---
|
|
//
|
|
// The deletion goroutine in DeleteAccountHandler retries transient Square
|
|
// failures and, on final failure, logs at ERROR and raises a critical admin
|
|
// notification (reason 'critical_payment_log'). A failed Square-side erasure
|
|
// must never be silently dropped: by the time the goroutine runs, the local
|
|
// anonymization has already committed and NULLed square_card_id/
|
|
// square_customer_id, so the external Square references would otherwise be
|
|
// orphaned — permanently, with no way to retrace them.
|
|
//
|
|
// Fault A1 (crash window): the erasure targets are ALSO persisted as a durable
|
|
// outbox INSIDE the anonymization transaction (persistSquareErasureOutbox),
|
|
// before it commits. The just-scrubbed, soft-deleted user_saved_cards rows keep
|
|
// their square_card_id / square_customer_id (and lose their user_id, matching
|
|
// delete_guest_user) until the Square-side erasure completes. If the process
|
|
// dies between the local commit and the async goroutine finishing, those rows
|
|
// are the only remaining record of the external Square PII; the
|
|
// retry-square-erasures job (internal/jobs/cleanup.go) finds them and retries
|
|
// the deletion. The goroutine drains the outbox (NULLing the refs on success);
|
|
// the job is the crash safety net. Either the local tx rolls back (nothing
|
|
// NULLed) or the durable queue guarantees the Square deletion is eventually
|
|
// attempted and alerted on failure.
|
|
|
|
const (
|
|
// squareDeleteMaxAttempts is the number of times a Square erasure call is
|
|
// attempted before it is treated as failed (1 initial + 2 retries).
|
|
squareDeleteMaxAttempts = 3
|
|
// squareDeleteAttemptTimeout bounds a single Square erasure attempt.
|
|
squareDeleteAttemptTimeout = 30 * time.Second
|
|
// squareDeleteBackoffBase is the delay before the first retry (doubled per
|
|
// subsequent retry).
|
|
squareDeleteBackoffBase = 500 * time.Millisecond
|
|
)
|
|
|
|
// squareErasureTarget records one saved-card row's external Square references
|
|
// (row id + square_card_id + square_customer_id) captured BEFORE anonymization
|
|
// NULLs them, so the durable outbox and the async cleanup goroutine still have
|
|
// the external refs they need to complete Square-side erasure (Fault A1).
|
|
type squareErasureTarget struct {
|
|
rowID string
|
|
cardID string // empty when the row had no Square card on file
|
|
customerID string // empty when the row had no Square customer profile
|
|
}
|
|
|
|
// squareDeletionRetryable reports whether a Square erasure error is transient
|
|
// and worth retrying: transport errors (wrapped *url.Error) and Square 5xx
|
|
// responses. Definitive 4xx rejections and not-found no-ops are NOT retried.
|
|
func squareDeletionRetryable(err error) bool {
|
|
if square.IsNotFound(err) {
|
|
return false
|
|
}
|
|
if sc := square.ErrorStatusCode(err); sc != 0 {
|
|
return sc >= http.StatusInternalServerError
|
|
}
|
|
var urlErr *url.Error
|
|
return errors.As(err, &urlErr)
|
|
}
|
|
|
|
// RetrySquareDeletion runs fn (a DeleteCardOnFile/DeleteCustomer call) up to
|
|
// squareDeleteMaxAttempts times, backing off between retries, retrying only
|
|
// transient failures (see squareDeletionRetryable). Returns the final error,
|
|
// nil on success. Exported for the retry-square-erasures job
|
|
// (internal/jobs/cleanup.go), which reuses the same retry budget as the
|
|
// account-deletion goroutine.
|
|
func RetrySquareDeletion(parent context.Context, fn func(context.Context) error) error {
|
|
var lastErr error
|
|
backoff := squareDeleteBackoffBase
|
|
for attempt := 1; attempt <= squareDeleteMaxAttempts; attempt++ {
|
|
if attempt > 1 {
|
|
select {
|
|
case <-time.After(backoff):
|
|
case <-parent.Done():
|
|
return lastErr
|
|
}
|
|
backoff *= 2
|
|
}
|
|
attemptCtx, cancel := context.WithTimeout(parent, squareDeleteAttemptTimeout)
|
|
err := fn(attemptCtx)
|
|
cancel()
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
lastErr = err
|
|
if !squareDeletionRetryable(err) {
|
|
return err
|
|
}
|
|
}
|
|
return lastErr
|
|
}
|
|
|
|
// squareErasureNotificationID derives a deterministic admin_notifications id
|
|
// for a failed Square-side erasure tied to key ("S" + 11 hex chars of a
|
|
// SHA-256 digest, mirroring the webhooks dispute-notification id scheme). For
|
|
// the account-deletion path key is the affected user id; for the
|
|
// retry-square-erasures job key is the user id when the outbox row still
|
|
// carries one, otherwise the outbox row id. One notification per key;
|
|
// re-delivery of the same failure is a no-op.
|
|
func squareErasureNotificationID(key string) string {
|
|
sum := sha256.Sum256([]byte("square-erasure-failure:" + key))
|
|
return "S" + hex.EncodeToString(sum[:])[:11]
|
|
}
|
|
|
|
// InsertSquareErasureCriticalNotification surfaces a failed Square-side
|
|
// erasure in the admin notification centre (reason 'critical_payment_log' —
|
|
// the DB-backed stand-in for CRITICAL logs that the payments sweep uses).
|
|
// user_id is deliberately NULL: by the time the async cleanup runs, the account
|
|
// may already be deleted (guests) or anonymized, and the deterministic id keeps
|
|
// exactly one notification per affected key. Exported for the
|
|
// retry-square-erasures job, which reuses the account-deletion alert scheme.
|
|
func InsertSquareErasureCriticalNotification(ctx context.Context, key string) {
|
|
// Fresh bounded context so a near-expiry deletion context cannot suppress
|
|
// the admin alert.
|
|
actx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
defer cancel()
|
|
// Round 2 Loop B finding 1: this is a 'critical_payment_log' insert site —
|
|
// the same atomic global cap as every other. The pre-check logs the
|
|
// suppression; the fold inside the INSERT enforces it atomically.
|
|
if adminnotify.CriticalLogsCapExceeded(actx, db.Conn, "critical_payment_log") {
|
|
slog.Error("failed to insert critical notification for failed Square erasure — unacknowledged 'critical_payment_log' queue at the cap", "key", key)
|
|
return
|
|
}
|
|
tag, err := db.Conn.Exec(actx, `
|
|
INSERT INTO admin_notifications (id, reason, booking_id, user_id, created_at)
|
|
SELECT $1, 'critical_payment_log'::admin_notification_reason, NULL, NULL, NOW()
|
|
WHERE (SELECT COUNT(*) FROM admin_notifications _an
|
|
WHERE _an.reason = 'critical_payment_log'
|
|
AND _an.acknowledged_at IS NULL) < $2
|
|
ON CONFLICT (id) DO NOTHING
|
|
`, squareErasureNotificationID(key), adminnotify.MaxUnacknowledgedCriticalLogs)
|
|
if err != nil {
|
|
slog.Error("failed to insert critical notification for failed Square erasure", "key", key, "err", err)
|
|
return
|
|
}
|
|
if int(tag.RowsAffected()) > 0 {
|
|
slog.Error("Square-side erasure FAILED after retries — critical notification raised", "key", key)
|
|
}
|
|
}
|
|
|
|
// persistSquareErasureOutbox writes the captured Square erasure targets back
|
|
// onto the just-scrubbed, soft-deleted user_saved_cards rows INSIDE the
|
|
// anonymization transaction, BEFORE it commits (Fault A1). anonymize_user() /
|
|
// delete_guest_user() NULL square_card_id/square_customer_id for GDPR
|
|
// scrubbing; this UPDATE restores them on the soft-deleted rows so the erasure
|
|
// targets survive the commit as a durable queue entry. user_id is also NULLed
|
|
// (delete_guest_user already does this for guests): payments.EnsureSquareCustomer
|
|
// reads square_customer_id across ALL of a user's rows regardless of
|
|
// deleted_at, so an outbox row that kept its user_id would let the erased
|
|
// identity's deleted Square customer resurface on a later save-card flow. The
|
|
// rows remain in place for the 7-year financial retention; only the user link
|
|
// is dropped. If the process crashes before the async Square cleanup finishes,
|
|
// the retry-square-erasures job (internal/jobs/cleanup.go) finds these rows and
|
|
// completes the external deletion — the erasure is never permanently lost.
|
|
func persistSquareErasureOutbox(ctx context.Context, tx pgx.Tx, targets []squareErasureTarget) error {
|
|
for _, t := range targets {
|
|
var cardID, customerID any
|
|
if t.cardID != "" {
|
|
cardID = t.cardID
|
|
}
|
|
if t.customerID != "" {
|
|
customerID = t.customerID
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
UPDATE user_saved_cards
|
|
SET square_card_id = $2, square_customer_id = $3, user_id = NULL
|
|
WHERE id = $1 AND deleted_at IS NOT NULL
|
|
`, t.rowID, cardID, customerID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// clearSquareErasureOutboxRows NULLs the durable outbox marker on the given
|
|
// soft-deleted user_saved_cards rows after their Square-side erasure is
|
|
// complete (or deliberately abandoned as still-referenced). Best-effort: the
|
|
// retry-square-erasures job re-covers any row left behind by a failure.
|
|
func clearSquareErasureOutboxRows(ctx context.Context, rowIDs []string) {
|
|
for _, rowID := range rowIDs {
|
|
if _, err := db.Conn.Exec(ctx, `
|
|
UPDATE user_saved_cards SET square_customer_id = NULL
|
|
WHERE id = $1 AND deleted_at IS NOT NULL
|
|
`, rowID); err != nil {
|
|
slog.Error("failed to clear Square erasure outbox for customer", "row", rowID, "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Current-password re-verification lockout (FIX 2 / FIX 3) ---
|
|
//
|
|
// DeleteAccountHandler and ChangePasswordHandler both re-verify the current
|
|
// password. Without a failed-attempt budget, a stolen session token lets an
|
|
// attacker brute-force that password with unlimited guesses (the 2FA code gate
|
|
// only applies when 2FA is enforced AND enabled). The budget below reuses the
|
|
// SAME users.failed_attempts / users.locked_until columns the login path uses
|
|
// (handlers/auth/local.go), so every current-password check shares one
|
|
// counter: after 5 consecutive failures the account locks for 15 minutes
|
|
// (escalating to 30 at 7+ and 60 at 10+, matching login), and a correct
|
|
// password resets it. clock.Now() keeps the Go-side lock check on the same UTC
|
|
// clock as the DB NOW() stamp that writes locked_until.
|
|
|
|
// errCurrentPasswordLockedOut is returned by checkCurrentPasswordLockout while
|
|
// the user's current-password budget is inside a lockout window.
|
|
var errCurrentPasswordLockedOut = errors.New("current-password attempts exhausted; account locked")
|
|
|
|
// checkCurrentPasswordLockout returns errCurrentPasswordLockedOut when the
|
|
// user's shared failed-attempt budget is locked, nil otherwise (or the raw DB
|
|
// error). Must run BEFORE the bcrypt compare so a locked account is rejected
|
|
// without paying the bcrypt cost.
|
|
func checkCurrentPasswordLockout(ctx context.Context, userID string) error {
|
|
var lockedUntil *time.Time
|
|
if err := db.Conn.QueryRow(ctx,
|
|
`SELECT locked_until FROM users WHERE id = $1`, userID).Scan(&lockedUntil); err != nil {
|
|
return err
|
|
}
|
|
if lockedUntil != nil && lockedUntil.After(clock.Now()) {
|
|
return errCurrentPasswordLockedOut
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// recordCurrentPasswordFailure atomically records a wrong current password on
|
|
// the shared failed-attempt budget and returns the NEW failed_attempts count
|
|
// plus the resulting locked_until. FIX 3: the increment AND the lockout
|
|
// escalation are a SINGLE atomic UPDATE ... RETURNING (the exact statement the
|
|
// login path uses), so N concurrent wrong-password requests can never race a
|
|
// check-then-increment — every failure is counted (no lost updates) and the
|
|
// escalation decision is computed from the returned count. Mirrors the login
|
|
// path's progressive lockout exactly (5 failures → 15 minutes, 7 → 30, 10 →
|
|
// 60); the lock extends on every failure past the threshold.
|
|
func recordCurrentPasswordFailure(ctx context.Context, userID string) (int, *time.Time, error) {
|
|
var newCount int
|
|
var newLockedUntil *time.Time
|
|
err := db.Conn.QueryRow(ctx, `
|
|
UPDATE users
|
|
SET failed_attempts = failed_attempts + 1,
|
|
locked_until = CASE
|
|
WHEN failed_attempts + 1 >= 5 THEN NOW() + (CASE
|
|
WHEN failed_attempts + 1 >= 10 THEN INTERVAL '60 minutes'
|
|
WHEN failed_attempts + 1 >= 7 THEN INTERVAL '30 minutes'
|
|
ELSE INTERVAL '15 minutes'
|
|
END)
|
|
ELSE locked_until
|
|
END
|
|
WHERE id = $1
|
|
RETURNING failed_attempts, locked_until
|
|
`, userID).Scan(&newCount, &newLockedUntil)
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
return newCount, newLockedUntil, nil
|
|
}
|
|
|
|
// resetCurrentPasswordFailures clears the shared failed-attempt budget after a
|
|
// correct current password. Best-effort: a failed reset only leaves the stale
|
|
// counter in place, and the next correct password clears it again.
|
|
func resetCurrentPasswordFailures(ctx context.Context, userID string) {
|
|
if _, err := db.Conn.Exec(ctx,
|
|
`UPDATE users SET failed_attempts = 0, locked_until = NULL WHERE id = $1`, userID); err != nil {
|
|
slog.Error("failed to reset current-password lockout state", "user_id", userID, "error", err)
|
|
}
|
|
}
|
|
|
|
// s3BucketUnsetWarningOnce throttles the missing-S3_PROFILE_PICS_BUCKET CRITICAL
|
|
// log to one line per process (FIX 2): the deletion-time skip is fail-closed,
|
|
// and the single loud warning makes the misconfiguration impossible to miss.
|
|
// A proper startup check for S3_PROFILE_PICS_BUCKET in main.go is tracked for
|
|
// a later round (another agent owns main.go).
|
|
var s3BucketUnsetWarningOnce sync.Once
|
|
|
|
func warnS3ProfilePicsBucketUnset() {
|
|
s3BucketUnsetWarningOnce.Do(func() {
|
|
log.Printf("CRITICAL: S3_PROFILE_PICS_BUCKET is not set — profile-picture deletion skipped fail-closed; profile-pic objects may remain until the bucket is configured (main.go startup check pending in a later round)")
|
|
})
|
|
}
|
|
|
|
// DeleteAccountRequest carries the re-verification credentials the handler now
|
|
// requires before erasing an account (finding 3): the current password (always)
|
|
// and, in enforced environments for a user with 2FA enabled, a fresh one-time
|
|
// verification code.
|
|
type DeleteAccountRequest struct {
|
|
CurrentPassword string `json:"current_password"`
|
|
VerificationCode string `json:"verification_code"`
|
|
}
|
|
|
|
// DELETE /api/user/account
|
|
func DeleteAccountHandler(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := mw.GetUserID(r.Context())
|
|
if !ok {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
var accountRole string
|
|
var profilePicURL sql.NullString
|
|
var passwordHash sql.NullString
|
|
var twoFactorEnabled bool
|
|
err := db.Conn.QueryRow(r.Context(), `SELECT account_role, profile_pic_url, password_hash, two_factor_enabled FROM users WHERE id = $1`, userID).
|
|
Scan(&accountRole, &profilePicURL, &passwordHash, &twoFactorEnabled)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
http.Error(w, "user not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
log.Printf("Failed to fetch user for deletion: %v", err)
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
ctx := r.Context()
|
|
|
|
// Finding 3: deleting an account is irreversible, so the session token alone
|
|
// must not be enough — an attacker who lifts a token (XSS, leaked localStorage)
|
|
// must not be able to erase the account. Re-verify the current password
|
|
// (mirroring ChangePasswordHandler's bcrypt compare) and, in enforced
|
|
// environments for a user with 2FA enabled, a fresh one-time code consumed by
|
|
// the shared core (twofa.VerifyForUser with ConsumeOnVerify).
|
|
var req DeleteAccountRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
// FIX 5: passwordless (social-only, NULL password_hash) accounts have no
|
|
// current password to re-verify — a stolen session token would otherwise
|
|
// erase the account with zero credential proof. Treat them as needing the
|
|
// 2FA gate (see below).
|
|
hasPassword := passwordHash.Valid && passwordHash.String != ""
|
|
if hasPassword {
|
|
// FIX 2: apply the shared current-password failed-attempt budget BEFORE
|
|
// the compare — a stolen session token must not be able to brute-force
|
|
// the current password with unlimited guesses.
|
|
//
|
|
// FIX 1 (round-9): a user locked out by LOGIN attacks (shared
|
|
// failed_attempts/locked_until columns) can still recover by providing
|
|
// the CORRECT current password here — the lockout is cleared on
|
|
// success. When locked out we still run the bcrypt compare (one
|
|
// attempt), and if the password is correct the lockout is lifted. If
|
|
// the password is wrong while locked out, no additional failure is
|
|
// recorded (the lockout stands).
|
|
lockedOut := false
|
|
if err := checkCurrentPasswordLockout(ctx, userID); err != nil {
|
|
if errors.Is(err, errCurrentPasswordLockedOut) {
|
|
lockedOut = true
|
|
} else {
|
|
log.Printf("Failed to check current-password lockout for user %s: %v", userID, err)
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash.String), []byte(req.CurrentPassword)); err != nil {
|
|
if lockedOut {
|
|
// FIX 1: already locked out — don't increment further.
|
|
http.Error(w, "too many failed attempts — try again later", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
// FIX 3: the failure record is ONE atomic UPDATE ... RETURNING
|
|
// (increment + escalation) — concurrent wrong-password requests
|
|
// cannot race a check-then-increment and lose updates.
|
|
newCount, _, recordErr := recordCurrentPasswordFailure(ctx, userID)
|
|
if recordErr != nil {
|
|
log.Printf("Failed to record current-password failure for user %s: %v", userID, recordErr)
|
|
}
|
|
if newCount >= 5 {
|
|
http.Error(w, "too many failed attempts — try again later", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
http.Error(w, "current password is incorrect", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
// FIX 1: a correct current password clears the shared lockout, so a
|
|
// login-locked-out user can self-recover by deleting their account.
|
|
resetCurrentPasswordFailures(ctx, userID)
|
|
}
|
|
// FIX 2 (round-9): passwordless accounts need 2FA only when enforcement is
|
|
// active (twoFARequired()). In unenforced environments (dev/test) the code
|
|
// cannot be minted (no delivery channel), so skip the 2FA gate — the
|
|
// passwordless property and the authenticated session are the protection.
|
|
// Has-password accounts need 2FA only when enforcement is active AND the
|
|
// user has 2FA enabled.
|
|
if twoFARequired() && (!hasPassword || twoFactorEnabled) {
|
|
if req.VerificationCode == "" {
|
|
http.Error(w, "a two-factor verification code is required to delete the account", http.StatusBadRequest)
|
|
return
|
|
}
|
|
switch err := twofa.VerifyForUser(ctx, userID, req.VerificationCode, twofa.ConsumeOnVerify); {
|
|
case err == nil:
|
|
// Verified: the code is consumed (single-use), matching the saved-card
|
|
// gate. If the deletion below then fails the user requests a fresh code.
|
|
case errors.Is(err, twofa.ErrIncorrect):
|
|
http.Error(w, "incorrect verification code", http.StatusBadRequest)
|
|
return
|
|
case errors.Is(err, twofa.ErrLockedOut):
|
|
http.Error(w, "Too many attempts. Request a new code.", http.StatusTooManyRequests)
|
|
return
|
|
case errors.Is(err, twofa.ErrMissingOrExpired):
|
|
http.Error(w, "verification code is missing or has expired", http.StatusBadRequest)
|
|
return
|
|
default:
|
|
log.Printf("failed to check 2FA pending code for user %s: %v", userID, err)
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
// --- External system scrubbing (BEFORE SQL anonymize) ---
|
|
|
|
// Durable S3/R2 profile-picture deletion (Fault A2 / FIX 1): instead of the
|
|
// old fire-and-forget goroutine, the deletion target is persisted as a
|
|
// pending_s3_deletions outbox row INSIDE the anonymization transaction
|
|
// below (before it commits), and an async goroutine drains it after the
|
|
// commit — mirroring the Square erasure outbox above. If the process dies
|
|
// between the local commit and the goroutine finishing, the
|
|
// retry-s3-deletions job (internal/jobs/cleanup.go) still finds the row and
|
|
// completes the object deletion, so the photo PII is never retained
|
|
// indefinitely.
|
|
profilePicS3Delete := profilePicURL.Valid && profilePicURL.String != "" && s3.Client != nil
|
|
// FIX 4 (fail-closed): never guess the dev bucket "crussell-profile-pics"
|
|
// when S3_PROFILE_PICS_BUCKET is unset — deleting from a guessed bucket
|
|
// would silently erase an unrelated deployment's objects. Skip the deletion
|
|
// and warn: the object may remain until the operator configures the bucket.
|
|
profilePicBucket := os.Getenv("S3_PROFILE_PICS_BUCKET")
|
|
if profilePicS3Delete && profilePicBucket == "" {
|
|
// FIX 2: one loud CRITICAL per process — a silent Warning can scroll by
|
|
// unnoticed in a busy log stream.
|
|
warnS3ProfilePicsBucketUnset()
|
|
profilePicS3Delete = false
|
|
}
|
|
// profiles/{userID}.jpg — matches UploadProfilePictureHandler key format
|
|
profilePicKey := fmt.Sprintf("profiles/%s.jpg", userID)
|
|
var profilePicDeletionID string
|
|
|
|
// Snapshot the Square card AND customer references per saved-card row
|
|
// synchronously BEFORE the SQL anonymization below NULLs
|
|
// square_card_id/square_customer_id, so both the durable outbox and the
|
|
// background cleanup still have the external Square references they need
|
|
// (previously the goroutine read the rows itself, racing the anonymize step
|
|
// which wiped them mid-flight). Distinct non-null customer IDs only: a
|
|
// user's saved cards share one provisioned Square customer, so
|
|
// DeleteCustomer runs once per customer. NULL customer IDs (users with no
|
|
// saved cards) are skipped.
|
|
var erasureTargets []squareErasureTarget
|
|
needsSquareErasure := false
|
|
// Capture the client synchronously so the async cleanup goroutine never
|
|
// reads the global payments.SquareClient (which tests swap per-account).
|
|
sqClient := payments.SquareClient
|
|
if sqClient != nil {
|
|
rows, err := db.Conn.Query(r.Context(),
|
|
`SELECT id, square_card_id, square_customer_id FROM user_saved_cards WHERE user_id = $1 AND deleted_at IS NULL`, userID)
|
|
if err != nil {
|
|
// Fail-closed: anonymization must never proceed without the
|
|
// external Square refs the cleanup needs — otherwise the card and
|
|
// customer would be permanently orphaned at Square with the local
|
|
// ids already NULLed (GDPR erasure completeness).
|
|
log.Printf("Failed to query saved cards for user %s: %v", userID, err)
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
for rows.Next() {
|
|
var t squareErasureTarget
|
|
var cardID, customerID sql.NullString
|
|
if err := rows.Scan(&t.rowID, &cardID, &customerID); err != nil {
|
|
log.Printf("Warning: Failed to scan card ID for user %s: %v", userID, err)
|
|
continue
|
|
}
|
|
if cardID.Valid && cardID.String != "" {
|
|
t.cardID = cardID.String
|
|
}
|
|
if customerID.Valid && customerID.String != "" {
|
|
t.customerID = customerID.String
|
|
}
|
|
if t.cardID != "" || t.customerID != "" {
|
|
needsSquareErasure = true
|
|
}
|
|
erasureTargets = append(erasureTargets, t)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
log.Printf("Failed to iterate saved cards for user %s: %v", userID, err)
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
rows.Close()
|
|
}
|
|
|
|
// --- SQL-level anonymization/deletion ---
|
|
|
|
if accountRole == "guest" {
|
|
tx, err := db.Conn.Begin(ctx)
|
|
if err != nil {
|
|
log.Printf("Failed to begin transaction for guest user deletion: %v", err)
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer func() {
|
|
if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
|
slog.Error("failed to rollback transaction", "err", err)
|
|
}
|
|
}()
|
|
|
|
_, err = tx.Exec(ctx, `SELECT delete_guest_user($1)`, userID)
|
|
if err != nil {
|
|
log.Printf("Failed to delete guest user %s: %v", userID, err)
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// A1 durable outbox: persist the Square erasure targets on the
|
|
// just-scrubbed, soft-deleted rows BEFORE this tx commits — if the
|
|
// process dies after the commit, the retry-square-erasures job still
|
|
// finds them.
|
|
if sqClient != nil && needsSquareErasure {
|
|
if err := persistSquareErasureOutbox(ctx, tx, erasureTargets); err != nil {
|
|
log.Printf("Failed to persist Square erasure outbox for guest %s: %v", userID, err)
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
// FIX 1a: the CardDAV vCard (full name/email/phone/DOB/photo URL PII —
|
|
// dav_cards, keyed by uri "{userID}.vcf" under addressbook 1, the same
|
|
// Postgres) is deleted INSIDE the erasure transaction, atomically with
|
|
// the local erasure. No outbox or retry job is needed: a failed tx
|
|
// rolls both back together. Replaces the old fire-and-forget
|
|
// dav.Service.DeleteContact goroutine that could only log on failure,
|
|
// permanently stranding the contact PII.
|
|
if _, err := tx.Exec(ctx, `
|
|
DELETE FROM dav_cards WHERE addressbookid = 1 AND uri = $1
|
|
`, fmt.Sprintf("%s.vcf", userID)); err != nil {
|
|
log.Printf("Failed to delete CardDAV contact for guest %s: %v", userID, err)
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// A2 durable outbox: persist the S3/R2 profile-picture deletion BEFORE
|
|
// this tx commits so the retry-s3-deletions job can complete it after
|
|
// a crash (the async goroutine below is the primary drain).
|
|
if profilePicS3Delete {
|
|
if err := tx.QueryRow(ctx, `
|
|
INSERT INTO pending_s3_deletions (user_id, bucket, object_key)
|
|
VALUES ($1, $2, $3)
|
|
RETURNING id
|
|
`, userID, profilePicBucket, profilePicKey).Scan(&profilePicDeletionID); err != nil {
|
|
log.Printf("Failed to persist S3 profile-pic deletion outbox for guest %s: %v", userID, err)
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
log.Printf("Failed to commit transaction for guest user deletion: %v", err)
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
} else {
|
|
tx, err := db.Conn.Begin(ctx)
|
|
if err != nil {
|
|
log.Printf("Failed to begin transaction for user anonymization: %v", err)
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer func() {
|
|
if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
|
slog.Error("failed to rollback transaction", "err", err)
|
|
}
|
|
}()
|
|
|
|
// anonymize_user() is the single source of truth for erasure: it NULLs
|
|
// the 2FA columns and staff notes in the same statement that anonymizes
|
|
// the rest of the row, so every call site (this handler AND the
|
|
// idle-account batch cleanup in scheduling.CleanupIdleAccounts) is
|
|
// GDPR-clean without a separate Go-side scrub.
|
|
_, err = tx.Exec(ctx, `SELECT anonymize_user($1)`, userID)
|
|
if err != nil {
|
|
log.Printf("Failed to anonymize user %s: %v", userID, err)
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// A1 durable outbox: persist the Square erasure targets on the
|
|
// just-scrubbed, soft-deleted rows BEFORE this tx commits — if the
|
|
// process dies after the commit, the retry-square-erasures job still
|
|
// finds them.
|
|
if sqClient != nil && needsSquareErasure {
|
|
if err := persistSquareErasureOutbox(ctx, tx, erasureTargets); err != nil {
|
|
log.Printf("Failed to persist Square erasure outbox for user %s: %v", userID, err)
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
// FIX 1a: delete the CardDAV vCard row inside the erasure transaction
|
|
// (atomic with the anonymization — see the guest path above).
|
|
if _, err := tx.Exec(ctx, `
|
|
DELETE FROM dav_cards WHERE addressbookid = 1 AND uri = $1
|
|
`, fmt.Sprintf("%s.vcf", userID)); err != nil {
|
|
log.Printf("Failed to delete CardDAV contact for user %s: %v", userID, err)
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// A2 durable outbox: persist the S3/R2 profile-picture deletion BEFORE
|
|
// this tx commits so the retry-s3-deletions job can complete it after
|
|
// a crash (the async goroutine below is the primary drain).
|
|
if profilePicS3Delete {
|
|
if err := tx.QueryRow(ctx, `
|
|
INSERT INTO pending_s3_deletions (user_id, bucket, object_key)
|
|
VALUES ($1, $2, $3)
|
|
RETURNING id
|
|
`, userID, profilePicBucket, profilePicKey).Scan(&profilePicDeletionID); err != nil {
|
|
log.Printf("Failed to persist S3 profile-pic deletion outbox for user %s: %v", userID, err)
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
log.Printf("Failed to commit transaction for user anonymization: %v", err)
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// TODO: Create 'user_anonymized' notification for admin audit trail
|
|
}
|
|
|
|
// The local erasure committed: drop the user's cached Square customer id so
|
|
// the erased identity cannot resurface from the process-local cache on a
|
|
// later save-card flow (the Square customer is deleted below and the DB
|
|
// columns are NULLed, but the cache is never touched by either).
|
|
payments.InvalidateSquareCustomerCache(userID)
|
|
|
|
// Finding 5: anonymize_user()/delete_guest_user() deleted every refresh
|
|
// token the user held inside the committed transaction. Drop the in-memory
|
|
// family-alive verdicts for ALL of the user's rotation families so access
|
|
// tokens minted by those families die on their next verification instead of
|
|
// riding the 30s family-alive cache TTL.
|
|
auth.InvalidateFamilyAliveByUser(userID)
|
|
|
|
// Build the context used for critical-notification inserts: route through
|
|
// the request transaction when one is active (tests) so alerts roll back
|
|
// with the fixture; otherwise fall back to the shared pool (production,
|
|
// where the request context is cancelled once this handler returns).
|
|
// Captured synchronously for the goroutine below.
|
|
notifyCtx := context.Background()
|
|
if activeTx := db.TxFromContext(r.Context()); activeTx != nil {
|
|
notifyCtx = db.ContextWithTx(notifyCtx, activeTx)
|
|
}
|
|
|
|
// external Square cleanup fires only after local anonymization/deletion
|
|
// commits, so a failed local tx leaves external state intact for retry. The
|
|
// durable outbox persisted above guarantees the targets survive even a
|
|
// process crash before this goroutine finishes — this goroutine is the
|
|
// primary drain, and the retry-square-erasures job is the safety net.
|
|
if sqClient != nil && needsSquareErasure {
|
|
// #nosec G118 — intentional background goroutine for async account deletion
|
|
go func(client square.SquareClient, targets []squareErasureTarget, notifyCtx context.Context) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
log.Printf("Panic recovered in Square cleanup: %v", r)
|
|
}
|
|
}()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
cardRow := map[string]string{}
|
|
for _, t := range targets {
|
|
if t.cardID != "" {
|
|
cardRow[t.cardID] = t.rowID
|
|
}
|
|
}
|
|
for cardID, rowID := range cardRow {
|
|
if err := RetrySquareDeletion(ctx, func(actx context.Context) error {
|
|
return client.DeleteCardOnFile(actx, cardID)
|
|
}); err != nil {
|
|
// TokenPrefix redacts the ccof: card token — the full ID must
|
|
// never reach logs. A failed erasure is logged at ERROR and
|
|
// raised as a critical admin notification — never silently
|
|
// dropped after the local refs are NULLed.
|
|
log.Printf("Error: Failed to delete Square card %s for user %s after %d attempts: %v", square.TokenPrefix(cardID), userID, squareDeleteMaxAttempts, err)
|
|
slog.Error("square card deletion failed after retries", "user_id", userID, "card", square.TokenPrefix(cardID), "error", err)
|
|
InsertSquareErasureCriticalNotification(notifyCtx, userID)
|
|
continue
|
|
}
|
|
// Drain the durable outbox so the safety-net job has nothing to
|
|
// retry. Best-effort: a failed clear leaves the entry in place
|
|
// for the job, which treats NOT_FOUND as complete.
|
|
if _, err := db.Conn.Exec(ctx, `
|
|
UPDATE user_saved_cards SET square_card_id = NULL
|
|
WHERE id = $1 AND deleted_at IS NOT NULL
|
|
`, rowID); err != nil {
|
|
slog.Error("failed to clear Square erasure outbox for card", "user_id", userID, "card", square.TokenPrefix(cardID), "error", err)
|
|
}
|
|
}
|
|
|
|
customerRows := map[string][]string{}
|
|
for _, t := range targets {
|
|
if t.customerID != "" {
|
|
customerRows[t.customerID] = append(customerRows[t.customerID], t.rowID)
|
|
}
|
|
}
|
|
for customerID, rows := range customerRows {
|
|
// Square customers are provisioned per-user from a deterministic
|
|
// email-derived key, but the UNIQUE(email) index excludes guest
|
|
// accounts — so a guest and a registered user sharing an email can
|
|
// end up on the SAME Square customer profile (Square dedups within
|
|
// its idempotency window). Deleting it would break the other
|
|
// account's saved-card charges, so skip the deletion when any OTHER
|
|
// non-deleted saved card still references the customer.
|
|
var stillReferenced bool
|
|
if err := db.Conn.QueryRow(ctx, `
|
|
SELECT EXISTS(SELECT 1 FROM user_saved_cards WHERE square_customer_id = $1 AND user_id <> $2 AND deleted_at IS NULL)
|
|
`, customerID, userID).Scan(&stillReferenced); err != nil {
|
|
log.Printf("Error: Failed to check Square customer %s references before deletion: %v", square.TokenPrefix(customerID), err)
|
|
slog.Error("failed to check Square customer references before deletion", "user_id", userID, "customer", square.TokenPrefix(customerID), "error", err)
|
|
InsertSquareErasureCriticalNotification(notifyCtx, userID)
|
|
continue
|
|
}
|
|
if stillReferenced {
|
|
// PII-redacted customer id — the full id never reaches logs.
|
|
log.Printf("Warning: skipping Square customer deletion — customer %s still referenced by another account", square.TokenPrefix(customerID))
|
|
// The customer is deliberately kept (still referenced by
|
|
// another account) — not a pending erasure, so drain the
|
|
// outbox rows and let the job stop retrying it.
|
|
clearSquareErasureOutboxRows(ctx, rows)
|
|
continue
|
|
}
|
|
if err := RetrySquareDeletion(ctx, func(actx context.Context) error {
|
|
return client.DeleteCustomer(actx, customerID)
|
|
}); err != nil {
|
|
log.Printf("Error: Failed to delete Square customer %s for user %s after %d attempts: %v", square.TokenPrefix(customerID), userID, squareDeleteMaxAttempts, err)
|
|
slog.Error("square customer deletion failed after retries", "user_id", userID, "customer", square.TokenPrefix(customerID), "error", err)
|
|
InsertSquareErasureCriticalNotification(notifyCtx, userID)
|
|
continue
|
|
}
|
|
clearSquareErasureOutboxRows(ctx, rows)
|
|
}
|
|
}(sqClient, erasureTargets, notifyCtx)
|
|
}
|
|
|
|
// Durable S3/R2 profile-picture deletion fires only after local
|
|
// anonymization/deletion commits, mirroring the Square goroutine above: the
|
|
// outbox row persisted in the erasure transaction survives a crash, this
|
|
// goroutine is the primary drain, and the retry-s3-deletions job
|
|
// (internal/jobs/cleanup.go) is the safety net.
|
|
if profilePicS3Delete {
|
|
// Capture the client synchronously so the async goroutine never reads
|
|
// the global s3.Client (which tests swap per-account).
|
|
picClient := s3.Client
|
|
// #nosec G118 — intentional background goroutine for async profile pic cleanup
|
|
go func(client s3.Uploader, bucket, key, outboxID string) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
log.Printf("Panic recovered in S3 profile picture deletion: %v", r)
|
|
}
|
|
}()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
if err := client.Delete(ctx, bucket, key); err != nil {
|
|
// The outbox row is deliberately left in place for the
|
|
// retry-s3-deletions job, so the photo PII is not retained
|
|
// indefinitely.
|
|
log.Printf("Warning: Failed to delete profile picture for user %s: %v", userID, err)
|
|
return
|
|
}
|
|
// Drain the durable outbox so the safety-net job has nothing to
|
|
// retry. Best-effort: a failed drain leaves the row for the job.
|
|
if _, err := db.Conn.Exec(ctx, `DELETE FROM pending_s3_deletions WHERE id = $1`, outboxID); err != nil {
|
|
slog.Error("failed to drain S3 profile-pic deletion outbox", "user_id", userID, "outbox_id", outboxID, "error", err)
|
|
}
|
|
}(picClient, profilePicBucket, profilePicKey, profilePicDeletionID)
|
|
}
|
|
|
|
// FIX 1a: the CardDAV vCard is deleted INSIDE the erasure transaction above
|
|
// (DELETE FROM dav_cards WHERE addressbookid = 1 AND uri = '{userID}.vcf'),
|
|
// atomically with the local erasure — the old fire-and-forget
|
|
// dav.Service.DeleteContact goroutine (which could only log on failure,
|
|
// permanently stranding the contact PII) is gone.
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|