feat(account): migrate a live account's owned servers to a new account (§B3 inherit)

Old account runs /felis migrate in-game to open a migration, proves control via a
fresh web step-up (passkey forced when enrolled, else email-OTP), names the target
and mints a one-time code. The target redeems it while authenticated AS that target:
in one transaction the source's owned servers re-point to the target and the source
is retired (sessions revoked, disabled, soft-deleted), which also spends the code so
it cannot be replayed. Only server ownership moves; the mc_uuid link and web
credentials stay with the source, so migrate is not a credential-theft primitive.

- 0015 migration: account_migrations state machine (initiated -> confirmed ->
  code_issued -> redeemed), one live migration per source
- Repo/PGRepo: Start/ForSource/Confirm/IssueCode/Redeem
- 8 routes (1 internal /felis side, 7 web) with openapi parity
- passkey step-up runs the same clone-signal (sign-count) check as the login door
- code bound to the named target at issue and at redeem

Quota is grandfathered at redeem: no per-target quota re-check when servers move.
This commit is contained in:
flyemoji committed 2026-07-05 20:50:48 +09:00
1 parent bbcfaeb6c4
commit fdb6efbd88
8 files changed
+1692

No files matched your search

+135
View File
@@ -94,6 +94,12 @@ type fakeRepo struct {
// pingErr, when non-nil, is returned by Ping to simulate DB liveness check
// failures in /readyz tests.
pingErr error
// account migration (spec §B3 inherit, scenario A) keyed by row id, mirroring
// account_migrations. Server ownership for the transfer is read/written on
// byName[*].OwnerID (the servers projection), and the source's retirement is
// applied to seededUsers, so the fake exercises the same all-or-nothing shape the
// PG transaction enforces.
migrations map[string]*fakeMigration
}
// fakePasskeyChallenge mirrors a webauthn_challenges row: its owner and purpose, the
@@ -213,6 +219,7 @@ func newFakeRepo() *fakeRepo {
discoverableChallenges: map[string]*fakeDiscoverableChallenge{},
fakeQuotas: map[string]*QuotaView{},
migrations: map[string]*fakeMigration{},
}
}
@@ -921,6 +928,134 @@ func (f *fakeRepo) SetUserDisabled(_ context.Context, userID string, disabled bo
return ErrNotFound
}
// ---- account migration fakes (spec §B3 inherit, scenario A) ----
// fakeMigration mirrors an account_migrations row through its state machine. Zero
// times mean the corresponding NULL column (not yet confirmed / no code issued).
type fakeMigration struct {
id string
sourceUserID string
targetUserID string
state string
confirmFactor string
confirmedAt time.Time
codeHash string
codeExpiresAt time.Time
redeemedAt time.Time
createdAt time.Time
}
func (m *fakeMigration) view() *MigrationView {
v := &MigrationView{
ID: m.id, SourceUserID: m.sourceUserID, TargetUserID: m.targetUserID,
State: m.state, ConfirmFactor: m.confirmFactor, CreatedAt: m.createdAt,
}
if !m.confirmedAt.IsZero() {
t := m.confirmedAt
v.ConfirmedAt = &t
}
if !m.codeExpiresAt.IsZero() {
t := m.codeExpiresAt
v.CodeExpiresAt = &t
}
return v
}
// userLive mirrors the PG "id = $1 AND deleted_at IS NULL" guard: a seeded, not-yet-
// retired user is live; an unknown or soft-deleted one is not.
func (f *fakeRepo) userLive(userID string) bool {
for _, su := range f.seededUsers {
if su.view.ID == userID {
return su.detail.DeletedAt == nil
}
}
return false
}
func (f *fakeRepo) StartMigration(_ context.Context, id, sourceUserID string, now time.Time) error {
if !f.userLive(sourceUserID) {
return ErrNotFound
}
for k, m := range f.migrations { // supersede any prior non-redeemed row for the source
if m.sourceUserID == sourceUserID && m.state != "redeemed" {
delete(f.migrations, k)
}
}
f.migrations[id] = &fakeMigration{
id: id, sourceUserID: sourceUserID, state: "initiated", createdAt: now,
}
return nil
}
func (f *fakeRepo) MigrationForSource(_ context.Context, sourceUserID string) (*MigrationView, error) {
for _, m := range f.migrations {
if m.sourceUserID == sourceUserID && m.state != "redeemed" {
return m.view(), nil
}
}
return nil, ErrNotFound
}
func (f *fakeRepo) ConfirmMigration(_ context.Context, sourceUserID, factor string, now time.Time) error {
for _, m := range f.migrations {
if m.sourceUserID == sourceUserID && m.state == "initiated" {
m.state = "confirmed"
m.confirmFactor = factor
m.confirmedAt = now
return nil
}
}
return ErrConflict
}
func (f *fakeRepo) IssueMigrationCode(_ context.Context, sourceUserID, targetUserID, codeHash string, expiresAt time.Time) error {
for _, m := range f.migrations {
if m.sourceUserID == sourceUserID && m.state == "confirmed" {
m.state = "code_issued"
m.targetUserID = targetUserID
m.codeHash = codeHash
m.codeExpiresAt = expiresAt
return nil
}
}
return ErrConflict
}
func (f *fakeRepo) RedeemMigration(_ context.Context, targetUserID, codeHash string, now time.Time) (string, []string, error) {
var mig *fakeMigration
for _, m := range f.migrations {
if m.codeHash == codeHash && m.targetUserID == targetUserID &&
m.state == "code_issued" && m.codeExpiresAt.After(now) {
mig = m
break
}
}
if mig == nil {
return "", nil, ErrLinkCodeInvalid
}
// Re-point every server the source owns to the target (byName holds pointers).
var moved []string
for name, rec := range f.byName {
if rec.OwnerID == mig.sourceUserID {
rec.OwnerID = targetUserID
moved = append(moved, name)
}
}
sort.Strings(moved) // deterministic for assertions
// Retire the source: disable + soft-delete.
for i, su := range f.seededUsers {
if su.view.ID == mig.sourceUserID {
f.seededUsers[i].view.Disabled = true
f.seededUsers[i].detail.Disabled = true
t := now
f.seededUsers[i].detail.DeletedAt = &t
}
}
mig.state = "redeemed"
mig.redeemedAt = now
return mig.sourceUserID, moved, nil
}
// ---- quota admin fakes ----
func (f *fakeRepo) GetQuotas(_ context.Context, userID string) (*QuotaView, error) {