diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index a976387..f0eceeb 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -162,6 +162,15 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { zap.Duration("interval", cfg.GuestReapInterval), zap.Duration("retention", cfg.GuestRetention)) + // Purge the account-deletion legal dossier past its retention TTL: the + // retained-identities journal, and the feedback thread + dossier PII of long-deleted + // accounts (chat is kept). Checked daily; the TTL is a two-year policy constant. + retentionReaper := account.NewRetentionReaper(accounts, account.RetentionTTL, logger) + go retentionReaper.Run(ctx, 24*time.Hour) + logger.Info("retention reaper started", + zap.Duration("interval", 24*time.Hour), + zap.Duration("retention", account.RetentionTTL)) + // Re-evaluate moderated-chat write access when a temporary block self-expires: // no operator action fires then, so the sweeper emits the chat-access-changed // event for lapsed blocks and the gateway re-pushes the chat-gate command. diff --git a/backend/internal/account/retention.go b/backend/internal/account/retention.go index 6014718..4c00386 100644 --- a/backend/internal/account/retention.go +++ b/backend/internal/account/retention.go @@ -8,10 +8,15 @@ import ( "github.com/go-jet/jet/v2/postgres" "github.com/google/uuid" + "go.uber.org/zap" "scrabble/backend/internal/postgres/jet/backend/table" ) +// RetentionTTL bounds how long the account-deletion legal dossier is kept before the +// reaper purges it: two years from the detach/deletion event (owner policy, 2026-07-03). +const RetentionTTL = 2 * 365 * 24 * time.Hour + // Reasons recorded on a retained_identities row: what detached the credential from its // account. The row is written just before the live identities row is removed, preserving // the legal dossier (which email/vk/tg was linked, and when) even as the identity frees @@ -63,3 +68,87 @@ func (s *Store) StampLastLogin(ctx context.Context, accountID uuid.UUID, ip stri } return nil } + +// ReapExpiredRetention purges retention data whose event is older than cutoff: every +// retained_identities row by its detached_at (covering unlink/change on live accounts as +// well as deleted ones), plus — for accounts tombstoned before cutoff — the retained +// feedback thread and the dossier PII (deleted_display_name, last_login_ip). Chat is kept +// (a shared game artifact), and the tombstone account row itself stays (its no-cascade +// foreign keys). It returns how many journal rows and feedback messages were removed. +func (s *Store) ReapExpiredRetention(ctx context.Context, cutoff time.Time) (identities, feedback int64, err error) { + cut := postgres.TimestampzT(cutoff) + delJournal := table.RetainedIdentities.DELETE(). + WHERE(table.RetainedIdentities.DetachedAt.LT(cut)) + res, err := delJournal.ExecContext(ctx, s.db) + if err != nil { + return 0, 0, fmt.Errorf("account: reap retained identities: %w", err) + } + identities, _ = res.RowsAffected() + + expired := postgres.SELECT(table.Accounts.AccountID). + FROM(table.Accounts). + WHERE(table.Accounts.DeletedAt.IS_NOT_NULL().AND(table.Accounts.DeletedAt.LT(cut))) + delFeedback := table.FeedbackMessages.DELETE(). + WHERE(table.FeedbackMessages.AccountID.IN(expired)) + fbRes, err := delFeedback.ExecContext(ctx, s.db) + if err != nil { + return identities, 0, fmt.Errorf("account: reap deleted feedback: %w", err) + } + feedback, _ = fbRes.RowsAffected() + + clearPII := table.Accounts.UPDATE(table.Accounts.DeletedDisplayName, table.Accounts.LastLoginIP). + SET(postgres.NULL, postgres.NULL). + WHERE( + table.Accounts.DeletedAt.IS_NOT_NULL(). + AND(table.Accounts.DeletedAt.LT(cut)). + AND(table.Accounts.DeletedDisplayName.IS_NOT_NULL(). + OR(table.Accounts.LastLoginIP.IS_NOT_NULL())), + ) + if _, err := clearPII.ExecContext(ctx, s.db); err != nil { + return identities, feedback, fmt.Errorf("account: clear expired dossier PII: %w", err) + } + return identities, feedback, nil +} + +// RetentionReaper periodically purges expired account-deletion retention data via +// Store.ReapExpiredRetention, mirroring GuestReaper: one background goroutine started once +// from main. +type RetentionReaper struct { + store *Store + ttl time.Duration + clock func() time.Time + log *zap.Logger +} + +// NewRetentionReaper constructs a reaper purging retention data older than ttl. log may be +// nil. +func NewRetentionReaper(store *Store, ttl time.Duration, log *zap.Logger) *RetentionReaper { + if log == nil { + log = zap.NewNop() + } + return &RetentionReaper{ + store: store, + ttl: ttl, + clock: func() time.Time { return time.Now().UTC() }, + log: log, + } +} + +// Run purges expired retention data on each tick until ctx is cancelled. +func (r *RetentionReaper) Run(ctx context.Context, interval time.Duration) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + idn, fb, err := r.store.ReapExpiredRetention(ctx, r.clock().Add(-r.ttl)) + if err != nil { + r.log.Warn("retention reap failed", zap.Error(err)) + } else if idn > 0 || fb > 0 { + r.log.Info("reaped expired retention", zap.Int64("identities", idn), zap.Int64("feedback", fb)) + } + } + } +} diff --git a/backend/internal/inttest/retention_test.go b/backend/internal/inttest/retention_test.go index 9b5e440..68cafd8 100644 --- a/backend/internal/inttest/retention_test.go +++ b/backend/internal/inttest/retention_test.go @@ -6,10 +6,12 @@ import ( "context" "database/sql" "testing" + "time" "github.com/google/uuid" "scrabble/backend/internal/account" + "scrabble/backend/internal/accountdelete" ) // lastLoginIP reads an account's stamped last-login IP ("" when unset). @@ -126,3 +128,65 @@ func TestStampLastLoginThrottles(t *testing.T) { t.Fatalf("throttled ip = %q, want unchanged 1.2.3.4", got) } } + +// TestReapExpiredRetention: the reaper keeps journal rows newer than the cutoff and purges +// older ones, and drops a long-deleted account's feedback thread + dossier PII. +func TestReapExpiredRetention(t *testing.T) { + ctx := context.Background() + store := account.NewStore(testDB) + deleter := accountdelete.NewDeleter(testDB) + + // An unlinked provider leaves a journal row detached "now". + tgExt := "tg-" + uuid.NewString() + acc, err := store.ProvisionByIdentity(ctx, account.KindTelegram, tgExt) + if err != nil { + t.Fatalf("provision: %v", err) + } + if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, "keep-"+uuid.NewString()+"@example.com", true); err != nil { + t.Fatalf("attach email: %v", err) + } + if err := store.RemoveIdentity(ctx, acc.ID, account.KindTelegram); err != nil { + t.Fatalf("unlink: %v", err) + } + // A cutoff before the detach keeps the row. + if _, _, err := store.ReapExpiredRetention(ctx, time.Now().Add(-time.Hour)); err != nil { + t.Fatalf("reap (early cutoff): %v", err) + } + if got := retainedRows(t, acc.ID); len(got) != 1 { + t.Fatalf("journal after early-cutoff reap = %+v, want kept", got) + } + // A cutoff after the detach purges it. + if _, _, err := store.ReapExpiredRetention(ctx, time.Now().Add(time.Hour)); err != nil { + t.Fatalf("reap (late cutoff): %v", err) + } + if got := retainedRows(t, acc.ID); len(got) != 0 { + t.Fatalf("journal after late-cutoff reap = %+v, want purged", got) + } + + // A deleted account past the cutoff loses its feedback thread and dossier PII. + del, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "ru", "", "Иван", "") + if err != nil { + t.Fatalf("provision deletee: %v", err) + } + if _, err := testDB.ExecContext(ctx, + "INSERT INTO feedback_messages (message_id, account_id, body, channel) VALUES ($1, $2, 'hi', 'web')", + uuid.New(), del.ID); err != nil { + t.Fatalf("insert feedback: %v", err) + } + if err := deleter.AnonymizeAndTombstone(ctx, del.ID); err != nil { + t.Fatalf("delete: %v", err) + } + if _, fb, err := store.ReapExpiredRetention(ctx, time.Now().Add(time.Hour)); err != nil || fb == 0 { + t.Fatalf("reap deleted = (fb %d, err %v), want fb>=1", fb, err) + } + if name, _ := deletedFields(t, del.ID); name != "" { + t.Errorf("deleted_display_name after reap = %q, want cleared", name) + } + var fbCount int + if err := testDB.QueryRowContext(ctx, "SELECT count(*) FROM feedback_messages WHERE account_id = $1", del.ID).Scan(&fbCount); err != nil { + t.Fatalf("count feedback: %v", err) + } + if fbCount != 0 { + t.Errorf("feedback rows after reap = %d, want 0", fbCount) + } +}