From 52e6378f404de3f58134580514d9c371ef286629 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 3 Jul 2026 12:01:43 +0200 Subject: [PATCH] feat(accountdelete): anonymize-and-tombstone deletion core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New accountdelete package: AnonymizeAndTombstone journals every credential to the retention log (reason=delete) then removes them (freeing email/vk/tg for reuse), snapshots the real display name into deleted_display_name and scrubs the live one to 'Удалённый игрок', sets deleted_at, anonymizes the account's game-seat snapshots, and drops its friendships/blocks/invitations/friend-codes/drafts/pending-codes — all in one transaction. Chat, feedback and complaints are kept (the tombstone keeps their no-cascade FKs valid). Session revocation + game forfeit are orchestrated a layer up. Integration test covers journalling, tombstone/scrub and credential reuse. --- backend/internal/accountdelete/delete.go | 189 +++++++++++++++++++++++ backend/internal/inttest/delete_test.go | 92 +++++++++++ 2 files changed, 281 insertions(+) create mode 100644 backend/internal/accountdelete/delete.go create mode 100644 backend/internal/inttest/delete_test.go diff --git a/backend/internal/accountdelete/delete.go b/backend/internal/accountdelete/delete.go new file mode 100644 index 0000000..cab653f --- /dev/null +++ b/backend/internal/accountdelete/delete.go @@ -0,0 +1,189 @@ +// Package accountdelete deactivates an account as legal retention, not erasure: it keeps +// the account row as a tombstone (its chat/complaint foreign keys have no cascade, so a +// hard delete is impossible) while journalling and freeing the account's credentials, +// anonymising the live surfaces, and dropping the account's own social/ephemeral rows. +// The retained_identities journal plus the tombstone (deleted_at, deleted_display_name, +// last_login_at/ip) form the admin/legal dossier; messages are deliberately kept. Session +// revocation and active-game forfeit are orchestrated one layer up (they need the session +// cache and the game service). See docs/ARCHITECTURE.md §9.1 and the retention TTL reaper. +package accountdelete + +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" + + "scrabble/backend/internal/postgres/jet/backend/model" + "scrabble/backend/internal/postgres/jet/backend/table" +) + +// AnonymizedName is the label a deleted account shows to opponents. Display names are +// stored strings resolved identically for every viewer (no per-viewer localisation in this +// codebase), so a single canonical label is used. +const AnonymizedName = "Удалённый игрок" + +// retainDelete is the retained_identities reason written when a credential is journalled +// because its account is being deleted. +const retainDelete = "delete" + +// Deleter performs the SQL-atomic part of account deletion over a Postgres handle. +type Deleter struct { + db *sql.DB + now func() time.Time +} + +// NewDeleter constructs a Deleter over db. +func NewDeleter(db *sql.DB) *Deleter { + return &Deleter{db: db, now: func() time.Time { return time.Now().UTC() }} +} + +// AnonymizeAndTombstone retires accountID atomically: it journals every live identity into +// retained_identities (reason=delete) then removes them so the credentials free for reuse, +// snapshots the real display name into deleted_display_name and scrubs the live one to +// AnonymizedName, sets deleted_at, anonymises the account's game-seat snapshots, and drops +// its friendships, blocks, invitations, friend codes, drafts and pending codes. Chat, +// feedback and complaints are kept (the surviving tombstone keeps their no-cascade foreign +// keys valid). It is idempotent-safe on an already-tombstoned account (re-journalling +// nothing, since the identities are already gone). +func (d *Deleter) AnonymizeAndTombstone(ctx context.Context, accountID uuid.UUID) error { + now := d.now() + return withTx(ctx, d.db, func(tx *sql.Tx) error { + if err := journalAndDropIdentities(ctx, tx, accountID, now); err != nil { + return err + } + if err := tombstone(ctx, tx, accountID, now); err != nil { + return err + } + if _, err := table.GamePlayers.UPDATE(table.GamePlayers.DisplayName). + SET(postgres.String(AnonymizedName)). + WHERE(table.GamePlayers.AccountID.EQ(postgres.UUID(accountID))). + ExecContext(ctx, tx); err != nil { + return fmt.Errorf("accountdelete: anonymise seats: %w", err) + } + return dropSocialAndEphemerals(ctx, tx, accountID) + }) +} + +// journalAndDropIdentities copies the account's live identities into the retention journal +// (reason=delete) and then removes them, freeing each (kind, external_id) for reuse. +func journalAndDropIdentities(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, now time.Time) error { + var ids []model.Identities + err := postgres.SELECT(table.Identities.AllColumns). + FROM(table.Identities). + WHERE(table.Identities.AccountID.EQ(postgres.UUID(accountID))). + QueryContext(ctx, tx, &ids) + if err != nil && !errors.Is(err, qrm.ErrNoRows) { + return fmt.Errorf("accountdelete: load identities: %w", err) + } + for _, id := range ids { + rid, err := uuid.NewV7() + if err != nil { + return fmt.Errorf("accountdelete: 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.DetachedAt, table.RetainedIdentities.Reason, + ).VALUES(rid, accountID, id.Kind, id.ExternalID, id.Confirmed, id.CreatedAt, now, retainDelete) + if _, err := ins.ExecContext(ctx, tx); err != nil { + return fmt.Errorf("accountdelete: retain identity %s: %w", id.Kind, err) + } + } + if _, err := table.Identities.DELETE(). + WHERE(table.Identities.AccountID.EQ(postgres.UUID(accountID))). + ExecContext(ctx, tx); err != nil { + return fmt.Errorf("accountdelete: delete identities: %w", err) + } + return nil +} + +// tombstone marks the account deleted, snapshotting the real display name into +// deleted_display_name (evaluated from the old row) before scrubbing the live one. +func tombstone(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, now time.Time) error { + upd := table.Accounts.UPDATE( + table.Accounts.DeletedAt, table.Accounts.DeletedDisplayName, + table.Accounts.DisplayName, table.Accounts.UpdatedAt, + ).SET( + postgres.TimestampzT(now), table.Accounts.DisplayName, + postgres.String(AnonymizedName), postgres.TimestampzT(now), + ).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(accountID))) + if _, err := upd.ExecContext(ctx, tx); err != nil { + return fmt.Errorf("accountdelete: tombstone account: %w", err) + } + return nil +} + +// dropSocialAndEphemerals removes the account's own friendships, blocks, invitations +// (as inviter and as invitee), friend codes, drafts and pending confirm-codes. These are +// the deleting user's private data with no dossier value; chat and feedback are kept. +func dropSocialAndEphemerals(ctx context.Context, tx *sql.Tx, accountID uuid.UUID) error { + id := postgres.UUID(accountID) + // Friendships and blocks are two-account edges keyed on either endpoint. + if _, err := table.Friendships.DELETE(). + WHERE(table.Friendships.RequesterID.EQ(id).OR(table.Friendships.AddresseeID.EQ(id))). + ExecContext(ctx, tx); err != nil { + return fmt.Errorf("accountdelete: delete friendships: %w", err) + } + if _, err := table.Blocks.DELETE(). + WHERE(table.Blocks.BlockerID.EQ(id).OR(table.Blocks.BlockedID.EQ(id))). + ExecContext(ctx, tx); err != nil { + return fmt.Errorf("accountdelete: delete blocks: %w", err) + } + // Invitations: drop the account's invitee rows, then its own invitations' invitees and + // the invitations themselves (children first, to respect the foreign key). + if _, err := table.GameInvitationInvitees.DELETE(). + WHERE(table.GameInvitationInvitees.AccountID.EQ(id)). + ExecContext(ctx, tx); err != nil { + return fmt.Errorf("accountdelete: delete invitee rows: %w", err) + } + ownInvitations := postgres.SELECT(table.GameInvitations.InvitationID). + FROM(table.GameInvitations). + WHERE(table.GameInvitations.InviterID.EQ(id)) + if _, err := table.GameInvitationInvitees.DELETE(). + WHERE(table.GameInvitationInvitees.InvitationID.IN(ownInvitations)). + ExecContext(ctx, tx); err != nil { + return fmt.Errorf("accountdelete: delete own invitation invitees: %w", err) + } + if _, err := table.GameInvitations.DELETE(). + WHERE(table.GameInvitations.InviterID.EQ(id)). + ExecContext(ctx, tx); err != nil { + return fmt.Errorf("accountdelete: delete invitations: %w", err) + } + // Ephemerals: friend codes, move drafts, pending confirm-codes. + if _, err := table.FriendCodes.DELETE(). + WHERE(table.FriendCodes.AccountID.EQ(id)).ExecContext(ctx, tx); err != nil { + return fmt.Errorf("accountdelete: delete friend codes: %w", err) + } + if _, err := table.GameDrafts.DELETE(). + WHERE(table.GameDrafts.AccountID.EQ(id)).ExecContext(ctx, tx); err != nil { + return fmt.Errorf("accountdelete: delete drafts: %w", err) + } + if _, err := table.EmailConfirmations.DELETE(). + WHERE(table.EmailConfirmations.AccountID.EQ(id)).ExecContext(ctx, tx); err != nil { + return fmt.Errorf("accountdelete: delete confirmations: %w", err) + } + return nil +} + +// withTx runs fn inside a transaction, committing on success and rolling back on error. +func withTx(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) error { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("accountdelete: begin tx: %w", err) + } + if err := fn(tx); err != nil { + _ = tx.Rollback() + return err + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("accountdelete: commit tx: %w", err) + } + return nil +} diff --git a/backend/internal/inttest/delete_test.go b/backend/internal/inttest/delete_test.go new file mode 100644 index 0000000..553b90f --- /dev/null +++ b/backend/internal/inttest/delete_test.go @@ -0,0 +1,92 @@ +//go:build integration + +package inttest + +import ( + "context" + "database/sql" + "testing" + "time" + + "github.com/google/uuid" + + "scrabble/backend/internal/account" + "scrabble/backend/internal/accountdelete" +) + +// deletedFields reads a tombstoned account's retained real name and its deleted_at. +func deletedFields(t *testing.T, accountID uuid.UUID) (name string, deletedAt sql.NullTime) { + t.Helper() + var dn sql.NullString + err := testDB.QueryRowContext(context.Background(), + "SELECT deleted_display_name, deleted_at FROM accounts WHERE account_id = $1", accountID). + Scan(&dn, &deletedAt) + if err != nil { + t.Fatalf("read deleted fields %s: %v", accountID, err) + } + return dn.String, deletedAt +} + +// TestAnonymizeAndTombstone: deletion journals + frees the credentials, tombstones the +// account, scrubs the live name while retaining the real one, and frees the creds for a +// new account to reuse. +func TestAnonymizeAndTombstone(t *testing.T) { + ctx := context.Background() + store := account.NewStore(testDB) + deleter := accountdelete.NewDeleter(testDB) + + acc, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "ru", "handle", "Иван", "+03:00") + if err != nil { + t.Fatalf("provision: %v", err) + } + email := "del-" + uuid.NewString() + "@example.com" + if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, email, true); err != nil { + t.Fatalf("attach email: %v", err) + } + before, err := store.GetByID(ctx, acc.ID) + if err != nil { + t.Fatalf("load before: %v", err) + } + + if err := deleter.AnonymizeAndTombstone(ctx, acc.ID); err != nil { + t.Fatalf("delete: %v", err) + } + + // The live identities are gone. + if ids, err := store.Identities(ctx, acc.ID); err != nil || len(ids) != 0 { + t.Fatalf("identities after delete = %+v (err %v), want none", ids, err) + } + // Both credentials are journalled with reason=delete. + got := retainedRows(t, acc.ID) + if len(got) != 2 { + t.Fatalf("retained rows = %+v, want 2", got) + } + for _, r := range got { + if r.reason != "delete" { + t.Errorf("retained reason = %q, want delete", r.reason) + } + } + // The live name is scrubbed; the real one is retained; deleted_at is set. + after, err := store.GetByID(ctx, acc.ID) + if err != nil { + t.Fatalf("load after: %v", err) + } + if after.DisplayName != accountdelete.AnonymizedName { + t.Errorf("live display name = %q, want %q", after.DisplayName, accountdelete.AnonymizedName) + } + name, deletedAt := deletedFields(t, acc.ID) + if name != before.DisplayName { + t.Errorf("retained name = %q, want %q", name, before.DisplayName) + } + if !deletedAt.Valid || time.Since(deletedAt.Time) > time.Minute { + t.Errorf("deleted_at = %+v, want a recent timestamp", deletedAt) + } + // The credentials are free: a new account can claim the same email. + other, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Other", "") + if err != nil { + t.Fatalf("provision other: %v", err) + } + if err := store.AttachIdentity(ctx, other.ID, account.KindEmail, email, true); err != nil { + t.Fatalf("email should be free after deletion, got: %v", err) + } +}