feat(account): journal detached credentials to the retention log
On unlink (RemoveIdentity, reason=unlink) and email change (replaceEmailIdentity, reason=change) write the outgoing credential to retained_identities before removing the live identities row — so the legal dossier survives while the (kind, external_id) frees for reuse. Same transaction, so the dossier and live state cannot diverge. Integration tests cover both reasons.
This commit is contained in:
@@ -546,6 +546,25 @@ func (s *Store) replaceEmailIdentity(ctx context.Context, confirmationID, accoun
|
|||||||
if _, err := upd.ExecContext(ctx, tx); err != nil {
|
if _, err := upd.ExecContext(ctx, tx); err != nil {
|
||||||
return fmt.Errorf("consume confirmation: %w", err)
|
return fmt.Errorf("consume confirmation: %w", err)
|
||||||
}
|
}
|
||||||
|
// Journal the outgoing email before replacing it, so the legal dossier keeps the
|
||||||
|
// address the account used to hold (see retention.go).
|
||||||
|
var old model.Identities
|
||||||
|
sel := postgres.SELECT(
|
||||||
|
table.Identities.ExternalID, table.Identities.Confirmed, table.Identities.CreatedAt,
|
||||||
|
).FROM(table.Identities).WHERE(
|
||||||
|
table.Identities.AccountID.EQ(postgres.UUID(accountID)).
|
||||||
|
AND(table.Identities.Kind.EQ(postgres.String(KindEmail))),
|
||||||
|
).LIMIT(1)
|
||||||
|
switch err := sel.QueryContext(ctx, tx, &old); {
|
||||||
|
case err == nil:
|
||||||
|
if err := retainIdentityTx(ctx, tx, accountID, KindEmail, old.ExternalID, old.Confirmed, old.CreatedAt, retainChange); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case errors.Is(err, qrm.ErrNoRows):
|
||||||
|
// No prior email (this doubles as an attach); nothing to retain.
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("load outgoing email identity: %w", err)
|
||||||
|
}
|
||||||
del := table.Identities.DELETE().WHERE(
|
del := table.Identities.DELETE().WHERE(
|
||||||
table.Identities.AccountID.EQ(postgres.UUID(accountID)).
|
table.Identities.AccountID.EQ(postgres.UUID(accountID)).
|
||||||
AND(table.Identities.Kind.EQ(postgres.String(KindEmail))),
|
AND(table.Identities.Kind.EQ(postgres.String(KindEmail))),
|
||||||
|
|||||||
@@ -31,21 +31,29 @@ func (s *Store) RemoveIdentity(ctx context.Context, accountID uuid.UUID, kind st
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
hasKind, others := false, 0
|
var toRetain []Identity
|
||||||
|
others := 0
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
if id.Kind == kind {
|
if id.Kind == kind {
|
||||||
hasKind = true
|
toRetain = append(toRetain, id)
|
||||||
} else {
|
} else {
|
||||||
others++
|
others++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !hasKind {
|
if len(toRetain) == 0 {
|
||||||
return ErrNotFound
|
return ErrNotFound
|
||||||
}
|
}
|
||||||
if others == 0 {
|
if others == 0 {
|
||||||
return ErrLastIdentity
|
return ErrLastIdentity
|
||||||
}
|
}
|
||||||
return withTx(ctx, s.db, func(tx *sql.Tx) error {
|
return withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||||
|
// Journal the detached credential before removing it, so the legal dossier
|
||||||
|
// survives while the identity frees for reuse (see retention.go).
|
||||||
|
for _, id := range toRetain {
|
||||||
|
if err := retainIdentityTx(ctx, tx, accountID, id.Kind, id.ExternalID, id.Confirmed, id.CreatedAt, retainUnlink); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
delID := table.Identities.DELETE().WHERE(
|
delID := table.Identities.DELETE().WHERE(
|
||||||
table.Identities.AccountID.EQ(postgres.UUID(accountID)).
|
table.Identities.AccountID.EQ(postgres.UUID(accountID)).
|
||||||
AND(table.Identities.Kind.EQ(postgres.String(kind))),
|
AND(table.Identities.Kind.EQ(postgres.String(kind))),
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/postgres/jet/backend/table"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package inttest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/account"
|
||||||
|
)
|
||||||
|
|
||||||
|
// retainedRow is one row of the retention journal, read directly for assertions.
|
||||||
|
type retainedRow struct {
|
||||||
|
kind, externalID, reason string
|
||||||
|
}
|
||||||
|
|
||||||
|
// retainedRows reads the retention journal for an account, oldest detach first.
|
||||||
|
func retainedRows(t *testing.T, accountID uuid.UUID) []retainedRow {
|
||||||
|
t.Helper()
|
||||||
|
rows, err := testDB.QueryContext(context.Background(),
|
||||||
|
"SELECT kind, external_id, reason FROM retained_identities WHERE account_id = $1 ORDER BY detached_at",
|
||||||
|
accountID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("query retained_identities %s: %v", accountID, err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []retainedRow
|
||||||
|
for rows.Next() {
|
||||||
|
var r retainedRow
|
||||||
|
if err := rows.Scan(&r.kind, &r.externalID, &r.reason); err != nil {
|
||||||
|
t.Fatalf("scan retained row: %v", err)
|
||||||
|
}
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnlinkJournalsRetainedIdentity: detaching a provider records it in the retention
|
||||||
|
// journal (reason=unlink) before the live identity is removed.
|
||||||
|
func TestUnlinkJournalsRetainedIdentity(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
store := account.NewStore(testDB)
|
||||||
|
|
||||||
|
tgExt := "tg-" + uuid.NewString()
|
||||||
|
acc, err := store.ProvisionByIdentity(ctx, account.KindTelegram, tgExt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("provision: %v", err)
|
||||||
|
}
|
||||||
|
email := "keep-" + uuid.NewString() + "@example.com"
|
||||||
|
if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, email, true); err != nil {
|
||||||
|
t.Fatalf("attach email: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.RemoveIdentity(ctx, acc.ID, account.KindTelegram); err != nil {
|
||||||
|
t.Fatalf("unlink telegram: %v", err)
|
||||||
|
}
|
||||||
|
got := retainedRows(t, acc.ID)
|
||||||
|
if len(got) != 1 || got[0].kind != account.KindTelegram || got[0].externalID != tgExt || got[0].reason != "unlink" {
|
||||||
|
t.Fatalf("retained rows = %+v, want one unlink telegram %q", got, tgExt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestChangeEmailJournalsOldAddress: an email change records the outgoing address in the
|
||||||
|
// retention journal (reason=change).
|
||||||
|
func TestChangeEmailJournalsOldAddress(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
store := account.NewStore(testDB)
|
||||||
|
mailer := &capturingMailer{}
|
||||||
|
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
|
||||||
|
|
||||||
|
acc, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("provision: %v", err)
|
||||||
|
}
|
||||||
|
oldAddr := "old-" + uuid.NewString() + "@example.com"
|
||||||
|
if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, oldAddr, true); err != nil {
|
||||||
|
t.Fatalf("attach old email: %v", err)
|
||||||
|
}
|
||||||
|
newAddr := "new-" + uuid.NewString() + "@example.com"
|
||||||
|
if err := svc.RequestChangeCode(ctx, acc.ID, newAddr); err != nil {
|
||||||
|
t.Fatalf("request change: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := svc.ConfirmChange(ctx, acc.ID, newAddr, sixDigit.FindString(mailer.lastBody)); err != nil {
|
||||||
|
t.Fatalf("confirm change: %v", err)
|
||||||
|
}
|
||||||
|
got := retainedRows(t, acc.ID)
|
||||||
|
if len(got) != 1 || got[0].kind != account.KindEmail || got[0].externalID != oldAddr || got[0].reason != "change" {
|
||||||
|
t.Fatalf("retained rows = %+v, want one change email %q", got, oldAddr)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user