fix(coderd/database): clean up org memberships when user is soft-deleted (#25149)

The soft-delete cleanup trigger (`delete_deleted_user_resources`)
removed `api_keys`, `user_links`, and `user_secrets` but left
`organization_members` rows intact. When a new user was created with a
previously-deleted user's email, both user IDs had org membership rows
in the same organization, producing duplicate-email members.

Extend the trigger to also delete `organization_members` for the
soft-deleted user. This cascades through the existing
`trigger_delete_group_members_on_org_member_delete`, which cleans up
group memberships automatically. The migration backfills by removing
zombie rows for already-deleted users.

Fixes ENG-831

> [!NOTE]
> 🤖 Generated by Coder Agents

<details>
<summary>Implementation notes</summary>

**Root cause**: `GetOrganizationIDsByMemberIDs` does not join on
`users.deleted = false`, so stale org membership rows for soft-deleted
users were visible to internal queries. Even the filtered queries
(`OrganizationMembers`, `PaginatedOrganizationMembers`) could surface
duplicate emails when a new active user reused a deleted user's email.

**What changed**:
- Migration 000491 extends `delete_deleted_user_resources()` to `DELETE
FROM organization_members WHERE user_id = OLD.id`
- Backfill removes existing zombie org memberships for soft-deleted
users
- `TestOrgMembersSoftDeleteTrigger` covers org membership removal, raw
row cleanup, and cascading group membership cleanup
</details>
This commit is contained in:
Seth Shelnutt
2026-05-12 16:20:25 -04:00
committed by GitHub
parent 9810d299d2
commit f355e010e8
4 changed files with 255 additions and 0 deletions
+171
View File
@@ -7811,6 +7811,177 @@ func TestUserSecretsSoftDeleteTrigger(t *testing.T) {
require.Contains(t, err.Error(), "Cannot create user_secret for deleted user")
}
// TestOrgMembersSoftDeleteTrigger verifies that a user's organization
// memberships (and transitively their group memberships) are deleted
// when the user is soft-deleted.
func TestOrgMembersSoftDeleteTrigger(t *testing.T) {
t.Parallel()
// SingleOrg verifies the basic case: one org, one group, and a
// control user whose membership must survive.
t.Run("SingleOrg", func(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitMedium)
org := dbgen.Organization(t, db, database.Organization{})
// userA will be soft-deleted.
userA := dbgen.User(t, db, database.User{})
dbgen.OrganizationMember(t, db, database.OrganizationMember{
OrganizationID: org.ID,
UserID: userA.ID,
})
// Add userA to a group in the org (should be cleaned up transitively).
group := dbgen.Group(t, db, database.Group{OrganizationID: org.ID})
dbgen.GroupMember(t, db, database.GroupMemberTable{
UserID: userA.ID,
GroupID: group.ID,
})
// userB is a control; their membership must not be touched.
userB := dbgen.User(t, db, database.User{})
dbgen.OrganizationMember(t, db, database.OrganizationMember{
OrganizationID: org.ID,
UserID: userB.ID,
})
dbgen.GroupMember(t, db, database.GroupMemberTable{
UserID: userB.ID,
GroupID: group.ID,
})
// Soft-delete userA.
require.NoError(t, db.UpdateUserDeletedByID(ctx, userA.ID))
// userA should no longer appear in the organization.
orgMembers, err := db.OrganizationMembers(ctx, database.OrganizationMembersParams{
OrganizationID: org.ID,
})
require.NoError(t, err)
var memberIDs []uuid.UUID
for _, m := range orgMembers {
memberIDs = append(memberIDs, m.OrganizationMember.UserID)
}
require.NotContains(t, memberIDs, userA.ID)
require.Contains(t, memberIDs, userB.ID)
// The raw org membership rows should also be gone (not just hidden).
rawOrgs, err := db.GetOrganizationIDsByMemberIDs(ctx, []uuid.UUID{userA.ID})
require.NoError(t, err)
require.Empty(t, rawOrgs, "zombie org membership rows should not exist after soft-delete")
// userA's group membership should also be removed by the cascading trigger.
groupMembers, err := db.GetGroupMembersByGroupID(ctx, database.GetGroupMembersByGroupIDParams{
GroupID: group.ID,
IncludeSystem: true,
})
require.NoError(t, err)
var groupMemberIDs []uuid.UUID
for _, gm := range groupMembers {
groupMemberIDs = append(groupMemberIDs, gm.UserID)
}
require.NotContains(t, groupMemberIDs, userA.ID)
require.Contains(t, groupMemberIDs, userB.ID)
})
// MultipleOrgs verifies that memberships are cleaned up across
// every organization the deleted user belonged to.
t.Run("MultipleOrgs", func(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitMedium)
org1 := dbgen.Organization(t, db, database.Organization{})
org2 := dbgen.Organization(t, db, database.Organization{})
// userA will be soft-deleted. They belong to both orgs.
userA := dbgen.User(t, db, database.User{})
dbgen.OrganizationMember(t, db, database.OrganizationMember{
OrganizationID: org1.ID,
UserID: userA.ID,
})
dbgen.OrganizationMember(t, db, database.OrganizationMember{
OrganizationID: org2.ID,
UserID: userA.ID,
})
// Add userA to a group in each org.
group1 := dbgen.Group(t, db, database.Group{OrganizationID: org1.ID})
dbgen.GroupMember(t, db, database.GroupMemberTable{
UserID: userA.ID,
GroupID: group1.ID,
})
group2 := dbgen.Group(t, db, database.Group{OrganizationID: org2.ID})
dbgen.GroupMember(t, db, database.GroupMemberTable{
UserID: userA.ID,
GroupID: group2.ID,
})
// userB stays in org1 as a control.
userB := dbgen.User(t, db, database.User{})
dbgen.OrganizationMember(t, db, database.OrganizationMember{
OrganizationID: org1.ID,
UserID: userB.ID,
})
dbgen.GroupMember(t, db, database.GroupMemberTable{
UserID: userB.ID,
GroupID: group1.ID,
})
// Soft-delete userA.
require.NoError(t, db.UpdateUserDeletedByID(ctx, userA.ID))
// userA should be gone from both orgs.
for _, org := range []database.Organization{org1, org2} {
members, err := db.OrganizationMembers(ctx, database.OrganizationMembersParams{
OrganizationID: org.ID,
})
require.NoError(t, err)
for _, m := range members {
require.NotEqual(t, userA.ID, m.OrganizationMember.UserID,
"userA should not appear in org %s", org.ID)
}
}
// No raw org membership rows should remain.
rawOrgs, err := db.GetOrganizationIDsByMemberIDs(ctx, []uuid.UUID{userA.ID})
require.NoError(t, err)
require.Empty(t, rawOrgs, "zombie org membership rows should not exist after soft-delete")
// Group memberships in both orgs should be cleaned up.
for _, g := range []struct {
name string
groupID uuid.UUID
}{
{"org1-group", group1.ID},
{"org2-group", group2.ID},
} {
groupMembers, err := db.GetGroupMembersByGroupID(ctx, database.GetGroupMembersByGroupIDParams{
GroupID: g.groupID,
IncludeSystem: true,
})
require.NoError(t, err, g.name)
for _, gm := range groupMembers {
require.NotEqual(t, userA.ID, gm.UserID, g.name)
}
}
// userB's memberships are unaffected.
org1Members, err := db.OrganizationMembers(ctx, database.OrganizationMembersParams{
OrganizationID: org1.ID,
})
require.NoError(t, err)
var org1MemberIDs []uuid.UUID
for _, m := range org1Members {
org1MemberIDs = append(org1MemberIDs, m.OrganizationMember.UserID)
}
require.Contains(t, org1MemberIDs, userB.ID)
})
}
func TestUserSecretsAuthorization(t *testing.T) {
t.Parallel()