fix: account security + GDPR erasure — current-password lockout budgets (atomic 15/30/60 escalation, uniform 401), DAV + S3 deletion durable in-tx, batch erasure outbox

- ChangePassword/DeleteAccount: failed-attempt lockout matching login escalation, atomic check-increment (no burst), uniform 401 with distinct bodies, passwordless accounts require 2FA unconditionally to delete, NULL-password change-password clear error
- erasure: CardDAV dav_cards rows deleted inside the erasure transaction (was fire-and-forget goroutine); S3 profile-pic deletion via pending_s3_deletions outbox + retry job; stale-guest/idle-account batch paths write the outbox in-tx and skip the guessed-bucket fallback
- S3_PROFILE_PICS_BUCKET unset -> fail-closed warning (once per process)
- scheduler test: 27 jobs (retry-s3-deletions)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
This commit is contained in:
2026-08-22 00:34:51 +01:00
co-authored by Sisyphus
parent 1d6d3e2f8d
commit 62dca184df
6 changed files with 748 additions and 101 deletions
+90
View File
@@ -16,6 +16,7 @@ import (
"crussell/handlers/scheduling"
"crussell/handlers/user"
"crussell/internal/adminnotify"
"crussell/internal/s3"
"crussell/internal/square"
"crussell/mw"
@@ -266,6 +267,21 @@ func RegisterAll(s *Scheduler) {
Concurrency: 1,
Handler: RetryPendingSquareErasures,
})
// Durable safety net for the account-deletion S3/R2 profile-picture outbox
// (Fault A2): retries the object deletions that DeleteAccountHandler's
// async cleanup goroutine could not finish (process crash or an S3 outage),
// using the pending_s3_deletions rows the handler persisted inside its
// anonymization tx before it committed. Hourly, offset from
// retry-square-erasures (:37 vs :17) so the two erasure jobs cannot
// contend.
s.Register(Job{
Name: "retry-s3-deletions",
Schedule: "37 * * * *", // Hourly at :37
Timeout: 30 * time.Second,
Concurrency: 1,
Handler: RetryPendingS3Deletions,
})
}
// SweepSquareWebhookEvents deletes square_webhook_events rows older than the
@@ -576,3 +592,77 @@ func RetryPendingSquareErasures(ctx context.Context) (int, error) {
}
return len(drained), nil
}
// RetryPendingS3Deletions is the durable safety net for the account-deletion
// S3/R2 profile-picture outbox (Fault A2). DeleteAccountHandler persists the
// deletion target (bucket + object key) inside its anonymization transaction,
// before it commits. If the process crashes between that commit and the async
// cleanup goroutine finishing, the photo's PII would otherwise be retained in
// the object store indefinitely — this job finds the pending rows and retries
// the deletion so the erasure is eventually complete. On success it deletes
// the outbox row; on failure it leaves the row in place (bumping attempts and
// recording the error) so the next run retries again. Returns the number of
// outbox rows drained.
func RetryPendingS3Deletions(ctx context.Context) (int, error) {
if s3.Client == nil {
// No object store configured: no deletion is possible, and the handler
// only writes outbox entries when a client was configured.
return 0, nil
}
client := s3.Client
rows, err := db.Conn.Query(ctx, `
SELECT id, bucket, object_key
FROM pending_s3_deletions
ORDER BY created_at
`)
if err != nil {
return 0, fmt.Errorf("failed to query pending S3 deletions: %w", err)
}
defer rows.Close()
type pendingDelete struct {
id string
bucket string
key string
}
var pending []pendingDelete
for rows.Next() {
var p pendingDelete
if err := rows.Scan(&p.id, &p.bucket, &p.key); err != nil {
return 0, fmt.Errorf("failed to scan pending S3 deletion: %w", err)
}
pending = append(pending, p)
}
if err := rows.Err(); err != nil {
return 0, fmt.Errorf("failed to iterate pending S3 deletions: %w", err)
}
if len(pending) == 0 {
return 0, nil
}
drained := 0
for _, p := range pending {
actx, cancel := context.WithTimeout(ctx, 30*time.Second)
err := client.Delete(actx, p.bucket, p.key)
cancel()
if err != nil {
if _, uerr := db.Conn.Exec(ctx, `
UPDATE pending_s3_deletions SET attempts = attempts + 1, last_error = $2
WHERE id = $1
`, p.id, err.Error()); uerr != nil {
return drained, fmt.Errorf("failed to record S3 deletion retry failure %s: %w", p.id, uerr)
}
log.Printf("Warning: retry-s3-deletions failed to delete profile picture %s (outbox %s): %v", p.key, p.id, err)
continue
}
if _, err := db.Conn.Exec(ctx, `DELETE FROM pending_s3_deletions WHERE id = $1`, p.id); err != nil {
return drained, fmt.Errorf("failed to drain S3 deletion outbox row %s: %w", p.id, err)
}
drained++
}
if drained > 0 {
log.Printf("[ERASURE] retry-s3-deletions drained %d pending S3 deletion outbox row(s)", drained)
}
return drained, nil
}
+3 -2
View File
@@ -413,8 +413,8 @@ func TestRegisterAll_RegistersExpectedJobs(t *testing.T) {
s := New()
RegisterAll(s)
if got := len(s.registry); got != 26 {
t.Fatalf("RegisterAll() registered %d jobs, want 26", got)
if got := len(s.registry); got != 27 {
t.Fatalf("RegisterAll() registered %d jobs, want 27", got)
}
registered := make(map[string]Job, len(s.registry))
@@ -494,6 +494,7 @@ func expectedJobNames() map[string]bool {
"apply-default-hours": true,
"scan-critical-payment-logs": true,
"retry-square-erasures": true,
"retry-s3-deletions": true,
}
}