feat(account): retention TTL reaper (2-year legal hold)
Daily background reaper purges the deletion dossier past its two-year TTL: every retained_identities row by detached_at (covering unlink/change on live accounts too), and — for accounts tombstoned before the cutoff — the retained feedback thread plus the dossier PII (deleted_display_name, last_login_ip). Chat is kept (a shared game artifact) and the tombstone row stays. Started from main next to the guest reaper. Integration test covers the cutoff boundary and the deleted-account feedback/PII purge.
This commit is contained in:
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user