Files
Crussell/backend/handlers/user/account.go
T
popertots f3d50f6990 Complete Square GDPR erasure: customer deletion and stale-guest scrub
Account deletion now snapshots card and customer IDs before the local anonymize transaction and dispatches the Square cleanup goroutine only after the tx commits, deleting each distinct Square customer once and skipping any customer still referenced by another user's card. AnonymizeStaleGuestAccounts also disables cards and deletes the guest's Square customer profile (PII) before NULLing references locally. Token redaction applied to all error logs.
2026-08-22 00:34:49 +01:00

233 lines
8.1 KiB
Go

package user
import (
"context"
"database/sql"
"errors"
"fmt"
"log"
"log/slog"
"net/http"
"os"
"time"
"crussell/db"
"crussell/handlers/payments"
"crussell/internal/dav"
"crussell/internal/s3"
"crussell/internal/square"
"crussell/mw"
"github.com/jackc/pgx/v5"
)
// 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
err := db.Conn.QueryRow(r.Context(), `SELECT account_role, profile_pic_url FROM users WHERE id = $1`, userID).
Scan(&accountRole, &profilePicURL)
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()
// --- External system scrubbing (BEFORE SQL anonymize) ---
// Delete profile picture from S3/R2
if profilePicURL.Valid && profilePicURL.String != "" && s3.Client != nil {
// #nosec G118 — intentional background goroutine for async profile pic cleanup
go func(picURL 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()
bucket := os.Getenv("S3_PROFILE_PICS_BUCKET")
if bucket == "" {
bucket = "crussell-profile-pics"
}
// profiles/{userID}.jpg — matches UploadProfilePictureHandler key format
key := fmt.Sprintf("profiles/%s.jpg", userID)
if err := s3.Client.Delete(ctx, bucket, key); err != nil {
log.Printf("Warning: Failed to delete profile picture for user %s: %v", userID, err)
}
}(profilePicURL.String)
}
// Snapshot the Square card IDs AND customer IDs synchronously BEFORE the
// SQL anonymization below NULLs square_card_id/square_customer_id, so the
// background cleanup still has the external Square references it needs
// (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 cardIDs []string
customerSeen := map[string]bool{}
var customerIDs []string
// 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 square_card_id, square_customer_id FROM user_saved_cards WHERE user_id = $1 AND deleted_at IS NULL`, userID)
if err != nil {
log.Printf("Warning: Failed to query saved cards for user %s: %v", userID, err)
} else {
for rows.Next() {
var cardID, customerID sql.NullString
if err := rows.Scan(&cardID, &customerID); err != nil {
log.Printf("Warning: Failed to scan card ID for user %s: %v", userID, err)
continue
}
if cardID.Valid && cardID.String != "" {
cardIDs = append(cardIDs, cardID.String)
}
if customerID.Valid && customerID.String != "" && !customerSeen[customerID.String] {
customerSeen[customerID.String] = true
customerIDs = append(customerIDs, customerID.String)
}
}
if err := rows.Err(); err != nil {
log.Printf("Warning: Row iteration error for user %s: %v", userID, err)
}
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
}
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)
}
}()
_, 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
}
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
}
// external Square cleanup fires only after local anonymization/deletion
// commits, so a failed local tx leaves external state intact for retry.
if sqClient != nil && (len(cardIDs) > 0 || len(customerIDs) > 0) {
// #nosec G118 — intentional background goroutine for async account deletion
go func(client square.SquareClient, cards, customers []string) {
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()
for _, cardID := range cards {
if err := client.DeleteCardOnFile(ctx, cardID); err != nil {
// TokenPrefix redacts the ccof: card token — the full ID must
// never reach logs.
log.Printf("Warning: Failed to delete Square card %s for user %s: %v", square.TokenPrefix(cardID), userID, err)
}
}
for _, customerID := range customers {
// 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("Warning: Failed to check Square customer %s references before deletion: %v", square.TokenPrefix(customerID), err)
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))
continue
}
if err := client.DeleteCustomer(ctx, customerID); err != nil {
log.Printf("Warning: Failed to delete Square customer %s for user %s: %v", square.TokenPrefix(customerID), userID, err)
}
}
}(sqClient, cardIDs, customerIDs)
}
// Delete CardDAV contact (non-blocking, best-effort)
if dav.Service != nil {
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("Panic recovered in CardDAV contact deletion: %v", r)
}
}()
uri := fmt.Sprintf("%s.vcf", userID)
if err := dav.Service.DeleteContact(1, uri); err != nil {
log.Printf("Warning: Failed to delete CardDAV contact for user %s: %v", userID, err)
}
}()
}
w.WriteHeader(http.StatusNoContent)
}