feat(account): deletion = legal retention, not erasure (PR3) #164
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user