0eefbfd6a4
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m2s
An account merge blanket-reassigned all of the secondary's identities to the primary, so merging two accounts that each had a confirmed email (or telegram/vk) left the survivor with two identities of one kind — which the profile and the retention dossier both treat as singular (the profile showed an arbitrary one; a later change-email journaled only one). The merge now keeps the primary's identity and journals the secondary's colliding one to retained_identities (reason=merge) before dropping it, so the survivor has one identity per kind and the absorbed credential still lands in the legal dossier. - migration 00008: widen retained_identities.reason CHECK to admit 'merge' (expand-contract — Up only widens the set, so an image rollback stays DB-safe). - accountmerge: dedupeIdentities + retainMergedIdentity before the identity reassign. - inttest TestAccountMergeDedupesEmail; docs ARCHITECTURE §4 + retention.go reason note.
225 lines
8.5 KiB
Go
225 lines
8.5 KiB
Go
package account
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/go-jet/jet/v2/postgres"
|
|
"github.com/go-jet/jet/v2/qrm"
|
|
"github.com/google/uuid"
|
|
"go.uber.org/zap"
|
|
|
|
"scrabble/backend/internal/postgres/jet/backend/model"
|
|
"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 (unlink / email change / account deletion here; an account merge that drops a
|
|
// same-kind colliding identity writes reason "merge" from the accountmerge package). 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 for reuse.
|
|
// See docs/ARCHITECTURE.md §9.1.
|
|
const (
|
|
retainUnlink = "unlink"
|
|
retainChange = "change"
|
|
retainDelete = "delete"
|
|
)
|
|
|
|
// retainIdentityTx appends a retention-journal row for one identity being detached, inside
|
|
// tx. linkedAt is the identity's original creation time; detached_at defaults to now(). It
|
|
// must run in the same transaction as the identity removal, so the dossier and the live
|
|
// state can never diverge.
|
|
func retainIdentityTx(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, kind, externalID string, confirmed bool, linkedAt time.Time, reason string) error {
|
|
id, err := uuid.NewV7()
|
|
if err != nil {
|
|
return fmt.Errorf("account: new retained id: %w", err)
|
|
}
|
|
ins := table.RetainedIdentities.INSERT(
|
|
table.RetainedIdentities.RetainedID, table.RetainedIdentities.AccountID,
|
|
table.RetainedIdentities.Kind, table.RetainedIdentities.ExternalID,
|
|
table.RetainedIdentities.Confirmed, table.RetainedIdentities.LinkedAt,
|
|
table.RetainedIdentities.Reason,
|
|
).VALUES(id, accountID, kind, externalID, confirmed, linkedAt, reason)
|
|
if _, err := ins.ExecContext(ctx, tx); err != nil {
|
|
return fmt.Errorf("account: retain identity (%s, %s): %w", kind, externalID, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// StampLastLogin records the account's last cold-load time and client IP, but only when
|
|
// the stored value is missing or older than an hour — so it costs at most one write per
|
|
// account per hour (its caller, the profile fetch, runs once per cold app-load). It is a
|
|
// best-effort audit signal that feeds the account-deletion dossier.
|
|
func (s *Store) StampLastLogin(ctx context.Context, accountID uuid.UUID, ip string) error {
|
|
now := time.Now().UTC()
|
|
upd := table.Accounts.UPDATE(table.Accounts.LastLoginAt, table.Accounts.LastLoginIP).
|
|
SET(postgres.TimestampzT(now), postgres.String(ip)).
|
|
WHERE(
|
|
table.Accounts.AccountID.EQ(postgres.UUID(accountID)).
|
|
AND(
|
|
table.Accounts.LastLoginAt.IS_NULL().
|
|
OR(table.Accounts.LastLoginAt.LT(postgres.TimestampzT(now.Add(-time.Hour)))),
|
|
),
|
|
)
|
|
if _, err := upd.ExecContext(ctx, s.db); err != nil {
|
|
return fmt.Errorf("account: stamp last login %s: %w", accountID, err)
|
|
}
|
|
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
|
|
}
|
|
|
|
// RetainedIdentity is one row of the retention journal, for the admin dossier.
|
|
type RetainedIdentity struct {
|
|
Kind string
|
|
ExternalID string
|
|
Reason string
|
|
Confirmed bool
|
|
LinkedAt time.Time
|
|
DetachedAt time.Time
|
|
}
|
|
|
|
// RetainedIdentities returns the account's retention-journal rows (the legal dossier of
|
|
// detached credentials), newest detach first, for the admin console.
|
|
func (s *Store) RetainedIdentities(ctx context.Context, accountID uuid.UUID) ([]RetainedIdentity, error) {
|
|
var rows []model.RetainedIdentities
|
|
err := postgres.SELECT(table.RetainedIdentities.AllColumns).
|
|
FROM(table.RetainedIdentities).
|
|
WHERE(table.RetainedIdentities.AccountID.EQ(postgres.UUID(accountID))).
|
|
ORDER_BY(table.RetainedIdentities.DetachedAt.DESC()).
|
|
QueryContext(ctx, s.db, &rows)
|
|
if err != nil && !errors.Is(err, qrm.ErrNoRows) {
|
|
return nil, fmt.Errorf("account: retained identities %s: %w", accountID, err)
|
|
}
|
|
out := make([]RetainedIdentity, 0, len(rows))
|
|
for _, r := range rows {
|
|
out = append(out, RetainedIdentity{
|
|
Kind: r.Kind, ExternalID: r.ExternalID, Reason: r.Reason,
|
|
Confirmed: r.Confirmed, LinkedAt: r.LinkedAt, DetachedAt: r.DetachedAt,
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// DeletionInfo is a tombstoned account's dossier header, for the admin console.
|
|
type DeletionInfo struct {
|
|
DeletedAt *time.Time
|
|
DeletedDisplayName string
|
|
LastLoginAt *time.Time
|
|
LastLoginIP string
|
|
}
|
|
|
|
// DeletionInfo reads the account's deletion tombstone + last-login dossier fields.
|
|
func (s *Store) DeletionInfo(ctx context.Context, accountID uuid.UUID) (DeletionInfo, error) {
|
|
var row model.Accounts
|
|
err := postgres.SELECT(
|
|
table.Accounts.DeletedAt, table.Accounts.DeletedDisplayName,
|
|
table.Accounts.LastLoginAt, table.Accounts.LastLoginIP,
|
|
).FROM(table.Accounts).
|
|
WHERE(table.Accounts.AccountID.EQ(postgres.UUID(accountID))).
|
|
QueryContext(ctx, s.db, &row)
|
|
if err != nil {
|
|
if errors.Is(err, qrm.ErrNoRows) {
|
|
return DeletionInfo{}, ErrNotFound
|
|
}
|
|
return DeletionInfo{}, fmt.Errorf("account: deletion info %s: %w", accountID, err)
|
|
}
|
|
info := DeletionInfo{DeletedAt: row.DeletedAt, LastLoginAt: row.LastLoginAt}
|
|
if row.DeletedDisplayName != nil {
|
|
info.DeletedDisplayName = *row.DeletedDisplayName
|
|
}
|
|
if row.LastLoginIP != nil {
|
|
info.LastLoginIP = *row.LastLoginIP
|
|
}
|
|
return info, 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))
|
|
}
|
|
}
|
|
}
|
|
}
|