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 } // FIX: admin accounts cannot be self-deleted if accountRole == "admin" { http.Error(w, "admin accounts cannot be self-deleted", http.StatusForbidden) return } // FIX: check for active bookings before guest deletion if accountRole == "guest" { var bookingCount int if err := db.Conn.QueryRow(r.Context(), `SELECT COUNT(*) FROM bookings WHERE user_id = $1 AND status IN ('pending', 'confirmed', 'in_progress', 'pending_release')`, userID).Scan(&bookingCount); err != nil { log.Printf("Failed to check bookings for guest %s: %v", userID, err) http.Error(w, "internal error", http.StatusInternalServerError) return } if bookingCount > 0 { http.Error(w, "cannot delete guest account with active bookings", http.StatusBadRequest) 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) }