fix(store): account deletion reads the owned workspaces inside its transaction under the owner lock, so a workspace minted or unjoined in the window no longer survives its deleted owner (BUG-3099) (#1567)

This commit is contained in:
xarmian
2026-09-26 09:47:46 -04:00
committed by GitHub
parent db02156b33
commit f6c007a668
8 changed files with 286 additions and 31 deletions
@@ -0,0 +1,118 @@
package server
import (
"net/http"
"testing"
"github.com/PerpetualSoftware/pad/internal/models"
"github.com/PerpetualSoftware/pad/internal/store"
"github.com/PerpetualSoftware/pad/internal/store/storetest"
)
// BUG-3099. The delete-account handler read the list of workspaces to delete
// BEFORE the deletion's transaction, and DeleteAccountAtomic soft-deleted
// only the slugs it was handed. A workspace the same account minted in
// between survived as a LIVE workspace owned by a deleted user.
func accountDeleteServer(t *testing.T, driver store.DriverType) *Server {
t.Helper()
var s *store.Store
if driver == store.DriverPostgres {
s = storetest.NewPostgres(t) // skips when PAD_TEST_POSTGRES_URL is unset
} else {
s = storetest.NewSQLite(t)
}
if got := s.D().Driver(); got != driver {
t.Fatalf("wanted a %s store, got %s", driver, got)
}
srv := New(s)
t.Cleanup(func() { srv.Stop() })
return srv
}
func liveWorkspace(t *testing.T, srv *Server, id string) bool {
t.Helper()
var n int
if err := srv.store.DB().QueryRow(srv.store.D().Rebind(
`SELECT COUNT(*) FROM workspaces WHERE id = ? AND deleted_at IS NULL`), id).Scan(&n); err != nil {
t.Fatalf("read workspace %s: %v", id, err)
}
return n == 1
}
// TestDeleteAccount_WorkspaceMintedInTheWindowIsDeleted mints the workspace
// from inside the billing-cancel call, which the handler makes AFTER it used
// to read the owned list and BEFORE the deletion transaction: the exact
// window the filing names, reached deterministically on the real door. The
// control workspace, owned before the request, was deleted before the fix
// too; if it survives, the harness is wrong, not the fix.
func TestDeleteAccount_WorkspaceMintedInTheWindowIsDeleted(t *testing.T) {
for _, driver := range []store.DriverType{store.DriverSQLite, store.DriverPostgres} {
t.Run(string(driver), func(t *testing.T) {
srv := accountDeleteServer(t, driver)
userID, token := bootstrapAccountDeleteUser(t, srv, "cus_window")
before, err := srv.store.CreateWorkspace(models.WorkspaceCreate{Name: "Before", Slug: "before-3099", OwnerID: userID})
if err != nil {
t.Fatal(err)
}
if err := srv.store.AddWorkspaceMember(before.ID, userID, "owner"); err != nil {
t.Fatal(err)
}
var minted *models.Workspace
fake := &fakeSidecar{}
fake.hook = func(string) {
ws, err := srv.store.CreateWorkspace(models.WorkspaceCreate{Name: "In window", Slug: "window-3099", OwnerID: userID})
if err != nil {
t.Errorf("mint in window: %v", err)
return
}
if err := srv.store.AddWorkspaceMember(ws.ID, userID, "owner"); err != nil {
t.Errorf("join in window: %v", err)
}
minted = ws
}
srv.SetCloudSidecar(fake)
rr := deleteAccountReq(srv, map[string]interface{}{"password": "correct-horse-battery-staple"}, token)
if rr.Code != http.StatusOK {
t.Fatalf("delete-account: got %d: %s", rr.Code, rr.Body.String())
}
if minted == nil {
t.Fatal("the window hook never minted; the test measured nothing")
}
if liveWorkspace(t, srv, before.ID) {
t.Fatal("control: a workspace owned before the request survived the deletion")
}
if liveWorkspace(t, srv, minted.ID) {
t.Fatal("a workspace minted between the owned-list read and the deletion survived as a live workspace of a deleted user")
}
})
}
}
// TestDeleteAccount_OwnedWorkspaceWithoutMembershipIsDeleted covers the
// second window the premise check found: the handler's list came from a
// MEMBERSHIP read, and CreateWorkspace commits the workspace before the
// owner's membership row is added, outside that transaction. A workspace
// that is owned but not joined was therefore invisible to the list with no
// race on the read at all.
func TestDeleteAccount_OwnedWorkspaceWithoutMembershipIsDeleted(t *testing.T) {
for _, driver := range []store.DriverType{store.DriverSQLite, store.DriverPostgres} {
t.Run(string(driver), func(t *testing.T) {
srv := accountDeleteServer(t, driver)
userID, token := bootstrapAccountDeleteUser(t, srv, "")
unjoined, err := srv.store.CreateWorkspace(models.WorkspaceCreate{Name: "Unjoined", Slug: "unjoined-3099", OwnerID: userID})
if err != nil {
t.Fatal(err)
}
rr := deleteAccountReq(srv, map[string]interface{}{"password": "correct-horse-battery-staple"}, token)
if rr.Code != http.StatusOK {
t.Fatalf("delete-account: got %d: %s", rr.Code, rr.Body.String())
}
if liveWorkspace(t, srv, unjoined.ID) {
t.Fatal("an owned workspace the owner had not joined survived the deletion")
}
})
}
}
+5 -15
View File
@@ -80,20 +80,10 @@ func (s *Server) handleDeleteAccount(w http.ResponseWriter, r *http.Request) {
}
}
// Delete all owned workspaces, sessions, and the user atomically.
// If any workspace deletion fails, the entire operation is aborted.
workspaces, err := s.store.GetUserWorkspaces(user.ID)
if err != nil {
writeInternalError(w, err)
return
}
var ownedSlugs []string
for _, ws := range workspaces {
if ws.OwnerID == user.ID {
ownedSlugs = append(ownedSlugs, ws.Slug)
}
}
// Owned workspaces, sessions and the user are deleted atomically below.
// The owned set is read INSIDE that transaction, under the user-row lock
// (BUG-3099): read here, before the Stripe cancel, a workspace minted in
// between survived as a live workspace of a deleted user.
// Cascade Stripe cancel BEFORE the local delete. If this ordering is
// reversed, a mid-flight failure would wipe the user's StripeCustomerID
@@ -128,7 +118,7 @@ func (s *Server) handleDeleteAccount(w http.ResponseWriter, r *http.Request) {
stripeWasCancelled = true
}
if err := s.store.DeleteAccountAtomic(user.ID, ownedSlugs); err != nil {
if err := s.store.DeleteAccountAtomic(user.ID); err != nil {
// Cross-system danger zone: if Stripe was already cancelled, the
// user's billing is gone but their account data is still present.
// Stripe cancel is NOT reversible programmatically. Operator must
@@ -44,7 +44,7 @@ func TestDeleteAccountAtomic_DetachesAuthoredComments(t *testing.T) {
}
// Must not fail on the comments.user_id FK.
if err := s.DeleteAccountAtomic(user.ID, []string{ws.Slug}); err != nil {
if err := s.DeleteAccountAtomic(user.ID); err != nil {
t.Fatalf("DeleteAccountAtomic: %v", err)
}
+7 -2
View File
@@ -36,7 +36,12 @@ func TestDeleteAccountAtomic_FullyPopulatedUser(t *testing.T) {
t.Fatalf("create other user: %v", err)
}
ws := createTestWorkspace(t, s, "Owned")
// OWNED means owner_id: the deletion reads its set by owner_id inside
// its transaction (BUG-3099), as the handler's list filtered on it.
ws, err := s.CreateWorkspace(models.WorkspaceCreate{Name: "Owned", OwnerID: u.ID})
if err != nil {
t.Fatalf("create owned workspace: %v", err)
}
coll := createTestCollection(t, s, ws.ID, "Tasks")
item1 := createTestItem(t, s, ws.ID, coll.ID, "Item One", "")
item2 := createTestItem(t, s, ws.ID, coll.ID, "Item Two", "")
@@ -151,7 +156,7 @@ func TestDeleteAccountAtomic_FullyPopulatedUser(t *testing.T) {
activitiesBefore := count(`SELECT COUNT(*) FROM activities`)
// The whole point: this must NOT fail on any FK.
if err := s.DeleteAccountAtomic(u.ID, []string{ws.Slug}); err != nil {
if err := s.DeleteAccountAtomic(u.ID); err != nil {
t.Fatalf("DeleteAccountAtomic on fully-populated user: %v", err)
}
@@ -0,0 +1,120 @@
package store
import (
"os"
"testing"
"time"
"github.com/PerpetualSoftware/pad/internal/models"
)
// BUG-3099, the lock order on Postgres. DeleteAccountAtomic takes the users-
// row lock a limited workspace mint takes (enforceUserLimitTx) as its FIRST
// statement and reads the owned set under it. These tests hold a real mint
// open mid-transaction at each of its two points and drive the deletion
// against it. SQLite is not driven: BEGIN IMMEDIATE serialises every writer,
// so the interleavings below cannot arise there.
func pgStoreFor3099(t *testing.T) *Store {
t.Helper()
url := os.Getenv("PAD_TEST_POSTGRES_URL")
if url == "" {
t.Skip("PAD_TEST_POSTGRES_URL not set; Postgres lock-order test skipped")
}
return testStorePostgres(t, url)
}
func deleteAsync(s *Store, userID string) <-chan error {
done := make(chan error, 1)
go func() { done <- s.DeleteAccountAtomic(userID) }()
return done
}
func liveOwned(t *testing.T, s *Store, wsID string) bool {
t.Helper()
var n int
if err := s.DB().QueryRow(s.q(`SELECT COUNT(*) FROM workspaces WHERE id = ? AND deleted_at IS NULL`), wsID).Scan(&n); err != nil {
t.Fatal(err)
}
return n == 1
}
// The mint holds the owner lock. The deletion must WAIT for it, and then see
// the committed workspace and delete it. Without the lock the deletion reads
// its set before the mint commits, and the workspace survives live.
func TestDeleteAccountAtomic_WaitsForAMintHoldingTheOwnerLock(t *testing.T) {
s := pgStoreFor3099(t)
u, err := s.CreateUser(models.UserCreate{Email: "m1@example.com", Name: "M", Password: "correct-horse-battery-staple"})
if err != nil {
t.Fatal(err)
}
tx, err := s.db.Begin()
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
ws, err := s.createWorkspaceQ(tx, models.WorkspaceCreate{Name: "Minting", OwnerID: u.ID})
if err != nil {
t.Fatal(err)
}
if err := s.enforceUserLimitTx(tx, u.ID, "workspaces", 1); err != nil {
t.Fatal(err)
}
done := deleteAsync(s, u.ID)
select {
case err := <-done:
t.Fatalf("deletion finished while the mint held the owner lock (err=%v): it read its set without waiting", err)
case <-time.After(500 * time.Millisecond):
}
if err := tx.Commit(); err != nil {
t.Fatal(err)
}
select {
case err := <-done:
if err != nil {
t.Fatalf("deletion: %v", err)
}
case <-time.After(10 * time.Second):
t.Fatal("deletion never finished after the mint committed")
}
if liveOwned(t, s, ws.ID) {
t.Fatal("the workspace the mint committed survived the deletion")
}
}
// The mint has inserted its row but not yet taken the lock. The deletion must
// NOT wait on it (the mint holds nothing on users: no deadlock), and the mint
// must then fail, finding no owner, so no workspace outlives its owner.
func TestDeleteAccountAtomic_DoesNotWaitOnAMintBeforeItsLock(t *testing.T) {
s := pgStoreFor3099(t)
u, err := s.CreateUser(models.UserCreate{Email: "m2@example.com", Name: "M", Password: "correct-horse-battery-staple"})
if err != nil {
t.Fatal(err)
}
tx, err := s.db.Begin()
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
ws, err := s.createWorkspaceQ(tx, models.WorkspaceCreate{Name: "Minting", OwnerID: u.ID})
if err != nil {
t.Fatal(err)
}
select {
case err := <-deleteAsync(s, u.ID):
if err != nil {
t.Fatalf("deletion: %v", err)
}
case <-time.After(10 * time.Second):
t.Fatal("deletion blocked on a mint that has not taken the owner lock")
}
if err := s.enforceUserLimitTx(tx, u.ID, "workspaces", 1); err == nil {
t.Fatal("the mint took the owner lock of a deleted user")
}
_ = tx.Rollback()
if liveOwned(t, s, ws.ID) {
t.Fatal("a workspace outlived its owner")
}
}
+1 -1
View File
@@ -526,7 +526,7 @@ func TestDeleteAccountAtomic_ScrubsOutbox(t *testing.T) {
return s.emitMemberEventTx(tx, kernelevents.MemberJoined, ws.ID, bystander.ID, "viewer", now())
})
if err := s.DeleteAccountAtomic(deleted.ID, nil); err != nil {
if err := s.DeleteAccountAtomic(deleted.ID); err != nil {
t.Fatalf("DeleteAccountAtomic: %v", err)
}
@@ -70,7 +70,7 @@ func TestGetInvitationByCode_SoftDeletedWorkspace(t *testing.T) {
t.Fatalf("CONTROL: a live workspace's invitation must be found; got %+v", before)
}
if err := s.DeleteAccountAtomic(owner.ID, []string{ws.Slug}); err != nil {
if err := s.DeleteAccountAtomic(owner.ID); err != nil {
t.Fatalf("DeleteAccountAtomic: %v", err)
}
if got, err := s.GetWorkspaceByID(ws.ID); err != nil || got != nil {
+33 -11
View File
@@ -967,7 +967,7 @@ func (s *Store) DeleteUser(id string) error {
// (ON DELETE CASCADE); items.assigned_user_id and activities.user_id
// (ON DELETE SET NULL; activities gained its FK action in migrations
// 072/050).
func (s *Store) DeleteAccountAtomic(userID string, ownedWorkspaceSlugs []string) error {
func (s *Store) DeleteAccountAtomic(userID string) error {
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("delete account: begin tx: %w", err)
@@ -976,16 +976,38 @@ func (s *Store) DeleteAccountAtomic(userID string, ownedWorkspaceSlugs []string)
ts := now()
// 1. Soft-delete all owned workspaces. They keep their rows (and every
// item/activity/comment within) so the data stays recoverable; only the
// user identity is hard-removed below.
for _, slug := range ownedWorkspaceSlugs {
if _, err := tx.Exec(s.q(`
UPDATE workspaces SET deleted_at = ?, updated_at = ?
WHERE slug = ? AND deleted_at IS NULL
`), ts, ts, slug); err != nil {
return fmt.Errorf("delete account: delete workspace %s: %w", slug, err)
}
// 0. Lock the user's row FIRST, with the lock every limited mint of a
// user-scoped row takes (enforceUserLimitTx, BUG-2808). The owned set
// below is read under it, so it is exact (BUG-3099): a mint that
// committed before the lock is in the set, and one still open waits
// here, then finds no user row when this commits and rolls back. The
// set used to be read by the HANDLER before this transaction began, and
// from memberships, so a workspace minted in between, or owned but not
// yet joined, survived as a live workspace of a deleted user. On SQLite
// the transaction is already BEGIN IMMEDIATE, which serialises every
// writer, and the row-locking clause would be a syntax error there.
//
// Deadlock-free against the mint because the mint's only write before
// its own lock is the workspaces row, which takes no lock on users (no
// foreign key from workspaces.owner_id on Postgres): it holds nothing
// this transaction waits for.
lock := `SELECT id FROM users WHERE id = ?`
if s.dialect.Driver() == DriverPostgres {
lock += ` FOR NO KEY UPDATE`
}
var lockedID string
if err := tx.QueryRow(s.q(lock), userID).Scan(&lockedID); err != nil {
return fmt.Errorf("delete account: lock user: %w", err)
}
// 1. Soft-delete every workspace the user OWNS. They keep their rows (and
// every item/activity/comment within) so the data stays recoverable; only
// the user identity is hard-removed below.
if _, err := tx.Exec(s.q(`
UPDATE workspaces SET deleted_at = ?, updated_at = ?
WHERE owner_id = ? AND deleted_at IS NULL
`), ts, ts, userID); err != nil {
return fmt.Errorf("delete account: delete owned workspaces: %w", err)
}
// exec runs one cleanup statement keyed on userID, wrapping the error with