fix: fresh-review round — 2FA deliverability, disable re-verification, GDPR batch scrub, dispute alerting, docs accuracy
Second fresh-eyes review pass (7 agents: goal, security, code-quality, context-mining, webhooks+2FA, client+mock+sweep, refunds/giftcards/handlers). Money-safety core verified sound (identical-body replay byte-lossless, clawback gated on definitive proof, no double-charge window). This round fixes the issues the fresh pass surfaced: 2FA: - Setup now DELIVERS the code via the [2FA] server log in ALL modes (was: nothing in enforced mode -> production 2FA was an unbreakable dead-end and saved-card charges were permanently 403). Enforced mode still withholds the code from the API response; the log line is the fake delivery channel until email/SMS lands (P6). - Disabling 2FA now requires a fresh verification code when enforcement is ON (previously ignored the code -> a password-only attacker could lift the gate). Shares the 5-attempt lockout and timing-safe compare. Dev bypass retained. - REQUIRE_2FA parsing normalized (false/0/off/no, case-insensitive); startup warning extended to the empty-env/mock-client/enforced-2FA confusion. GDPR: - anonymize_user() SQL now scrubs two_factor_* columns + staff notes, so the idle-account batch cleanup (CleanupIdleAccounts) is erasure-clean, not just the user-initiated delete path. Webhooks: - dispute.created for an untracked Square payment now raises a critical_payment_log admin notification (chargeback the app can't reconcile is never silent). Reason strings truncated on rune boundaries (valid UTF-8). Stale at-most-once comment corrected; revertTillSaleGiftCardFunding duplication noted. Sweep/mock parity: - Mock CreatePayment dedup is now source-aware (IDEMPOTENCY_KEY_REUSED on source mismatch) matching ReplayPaymentByKey and real Square. - COMPLETED-but-never-polled terminal till-sale checkouts are now recorded by the sweep (previously only booking checkouts were; till charges were invisible until the 24h blind-fail WARN). - Legacy snapshot-less minimal-body replay, SQUARE_LOCATION_ID drift, and in-memory-mock-restart limitations documented. Docs: - Webhook path corrected everywhere (/webhooks/square, not /api/webhooks/square - a deployer following the old path would 404 and silently lose all webhook reconciliation). - 2FA enforcement semantics + code-delivery mechanism documented accurately (fail-closed default; log-delivery channel; disable re-verification). - README/User Manual note the 2FA requirement on online saved-card payments. Tests: 2,151 (up from 2,142). Backend 26/27 packages green (crussell/db fails only in this environment: local postgres doesn't offer scram-sha-256 for the test role; package is byte-identical to HEAD and untouched here). Frontend builds; svelte-check 0 errors.
This commit is contained in:
@@ -21,24 +21,6 @@ import (
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// scrubAnonymizedUser2FA nulls the 2FA columns and staff notes that the SQL
|
||||
// anonymize_user() function does not scrub: it predates the 2FA columns and
|
||||
// intentionally preserves notes. A deleted user's live 2FA credential and any
|
||||
// PII in staff notes must not survive erasure, so run this inside the same
|
||||
// transaction as anonymize_user() to keep erasure atomic.
|
||||
func scrubAnonymizedUser2FA(ctx context.Context, q db.Querier, userID string) error {
|
||||
_, err := q.Exec(ctx, `
|
||||
UPDATE users
|
||||
SET two_factor_enabled = FALSE,
|
||||
two_factor_method = NULL,
|
||||
two_factor_pending_code_hash = NULL,
|
||||
two_factor_pending_code_expires = NULL,
|
||||
notes = NULL
|
||||
WHERE id = $1
|
||||
`, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// DELETE /api/user/account
|
||||
func DeleteAccountHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := mw.GetUserID(r.Context())
|
||||
@@ -169,6 +151,11 @@ func DeleteAccountHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}()
|
||||
|
||||
// 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)
|
||||
@@ -176,15 +163,6 @@ func DeleteAccountHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// GDPR erasure gap: anonymize_user() leaves the 2FA columns and staff
|
||||
// notes on the row. Scrub them here, in the same transaction, so erasure
|
||||
// is atomic with the anonymization.
|
||||
if err := scrubAnonymizedUser2FA(ctx, tx, userID); err != nil {
|
||||
log.Printf("Failed to scrub 2FA fields 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)
|
||||
|
||||
Reference in New Issue
Block a user