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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user