Skip to content

Commit 5decdd7

Browse files
adlerhurstcursoragentfforootd
authored
fix(database): separate user lifecycle ownership from team membership (#287)
<!-- CURSOR_AGENT_PR_BODY_BEGIN --> ## Summary Fixes #267 Align user/team storage with [ADR 024](docs/adrs/024-user-team-lifecycle-ownership.md): - Replace `users.team_id` with nullable `lifecycle_owner_team_id` (NULL = self-owned, set = team-owned) - Add `users.status` (active/suspended/deactivated/pending_purge) - Add `team_memberships` N:N table with membership status - Add `teams.status` and lifecycle `Deactivate` methods on team/user repositories - Remove `ON DELETE CASCADE` on the user/team graph; lifecycle deletes via repository policy - Backfill existing `team_id` data in migration `000011_user_team_lifecycle.sql` (Postgres + Spanner) ## Validation - `go test -tags=postgres_integration ./internal/storage/database/repository/... -count=1` - `corepack pnpm run check -- --only go` ## Release notes / changeset `.changeset/user-team-lifecycle-storage.md` — `@zitadel/server` patch: align user/team lifecycle storage with ADR 024. ## Notes Closes #267 Branch merged with `main` before review fixes. Harness mutex fix landed via #289. Migration backfill maps legacy `users.team_id` to `lifecycle_owner_team_id` as a conservative alpha default (old schema required a team context, so self-serve vs enterprise provenance cannot be distinguished retroactively). New creates stay self-owned unless `LifecycleOwnerTeamID` is set. Acceptance checklist: - [x] Self-owned user survives team deletion - [x] Team-owned user deactivated when owning team deleted - [x] Deleting a user does not cascade-delete teams they created - [x] No ON DELETE CASCADE on user/team graph - [x] Team-owned users lose all memberships when owning team is deactivated <!-- CURSOR_AGENT_PR_BODY_END --> <div><a href="https://cursor.com/agents/bc-f52f2c82-7c40-4962-bf78-39a8915c3d00"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a href="https://cursor.com/background-agent?bcId=bc-f52f2c82-7c40-4962-bf78-39a8915c3d00"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img alt="Open in Cursor" width="131" height="28" src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Silvan <adlerhurst@users.noreply.github.com> Co-authored-by: Florian Forster <florian@zitadel.com>
1 parent 6d88a39 commit 5decdd7

34 files changed

Lines changed: 1716 additions & 211 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@zitadel/server": patch
3+
---
4+
5+
Align user/team lifecycle storage with ADR 024: separate lifecycle ownership from team membership.

internal/api/integration_test/passkey_flow_test.go

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -89,11 +89,11 @@ func TestPasskeyFlowLogin(t *testing.T) {
8989
decoyEmailAttr, err := domain.NewCreateAttribute("email", "pk-flow-test@example.com", domain.AttributeUniquenessUnspecified)
9090
require.NoError(t, err)
9191
require.NoError(t, userRepo.Create(t.Context(), db, &domain.CreateUser{
92-
ProjectID: decoyProject.ID,
93-
SchemaURL: userSchemaURL,
94-
ID: userID,
95-
TeamID: &decoyTeam.ID,
96-
Attributes: []*domain.CreateAttribute{decoyEmailAttr},
92+
ProjectID: decoyProject.ID,
93+
SchemaURL: userSchemaURL,
94+
ID: userID,
95+
InitialMembershipTeamID: &decoyTeam.ID,
96+
Attributes: []*domain.CreateAttribute{decoyEmailAttr},
9797
}))
9898
decoyCred := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2)
9999
require.NoError(t, passkeyRepo.Create(t.Context(), db, &domain.CreateUserPasskey{
@@ -110,11 +110,11 @@ func TestPasskeyFlowLogin(t *testing.T) {
110110
require.NoError(t, err)
111111

112112
require.NoError(t, userRepo.Create(t.Context(), db, &domain.CreateUser{
113-
ProjectID: project.ID,
114-
SchemaURL: userSchemaURL,
115-
ID: userID,
116-
TeamID: &team.ID,
117-
Attributes: []*domain.CreateAttribute{emailAttr},
113+
ProjectID: project.ID,
114+
SchemaURL: userSchemaURL,
115+
ID: userID,
116+
InitialMembershipTeamID: &team.ID,
117+
Attributes: []*domain.CreateAttribute{emailAttr},
118118
}))
119119

120120
require.NoError(t, passkeyRepo.Create(t.Context(), db, &domain.CreateUserPasskey{

internal/api/integration_test/password_flow_test.go

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,11 @@ func TestPasswordLoginFlow(t *testing.T) {
4646

4747
userRepo := harness.EnsureUserRepo(t)
4848
require.NoError(t, userRepo.Create(t.Context(), db, &domain.CreateUser{
49-
ProjectID: project.ID,
50-
SchemaURL: schemaURL,
51-
ID: userID,
52-
TeamID: &team.ID,
53-
Attributes: []*domain.CreateAttribute{emailAttr},
49+
ProjectID: project.ID,
50+
SchemaURL: schemaURL,
51+
ID: userID,
52+
InitialMembershipTeamID: &team.ID,
53+
Attributes: []*domain.CreateAttribute{emailAttr},
5454
}))
5555

5656
hasher := harness.EnsureHasher(t)
@@ -270,11 +270,11 @@ func TestPasswordRegisterFlow_DuplicateEmail(t *testing.T) {
270270
emailAttr, err := domain.NewCreateAttribute("email", conflictEmail, domain.AttributeUniquenessProject)
271271
require.NoError(t, err)
272272
require.NoError(t, harness.EnsureUserRepo(t).Create(t.Context(), harness.EnsureDBPool(t), &domain.CreateUser{
273-
ProjectID: project.ID,
274-
SchemaURL: schemaURL,
275-
ID: "pwregister-conflict-seed",
276-
TeamID: &team.ID,
277-
Attributes: []*domain.CreateAttribute{emailAttr},
273+
ProjectID: project.ID,
274+
SchemaURL: schemaURL,
275+
ID: "pwregister-conflict-seed",
276+
InitialMembershipTeamID: &team.ID,
277+
Attributes: []*domain.CreateAttribute{emailAttr},
278278
}))
279279

280280
server := harness.EnsureTestServer(t)

internal/api/integration_test/registration_flow_test.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -78,11 +78,11 @@ func TestPasskeyRegistrationFlow(t *testing.T) {
7878

7979
userRepo := harness.EnsureUserRepo(t)
8080
require.NoError(t, userRepo.Create(t.Context(), db, &domain.CreateUser{
81-
ProjectID: project.ID,
82-
SchemaURL: userSchemaURL,
83-
ID: userID,
84-
TeamID: &team.ID,
85-
Attributes: []*domain.CreateAttribute{emailAttr},
81+
ProjectID: project.ID,
82+
SchemaURL: userSchemaURL,
83+
ID: userID,
84+
InitialMembershipTeamID: &team.ID,
85+
Attributes: []*domain.CreateAttribute{emailAttr},
8686
}))
8787

8888
passkeyRepo := harness.EnsureUserPasskeyRepo(t)

internal/api/integration_test/user_list_test.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -64,11 +64,11 @@ func TestListUsers(t *testing.T) {
6464
"email", fmt.Sprintf("list-%d@example.com", i), domain.AttributeUniquenessProject)
6565
require.NoError(t, err)
6666
require.NoError(t, userRepo.Create(t.Context(), db, &domain.CreateUser{
67-
ProjectID: project.ID,
68-
SchemaURL: schemaURL,
69-
ID: id,
70-
TeamID: &team.ID,
71-
Attributes: []*domain.CreateAttribute{emailAttr},
67+
ProjectID: project.ID,
68+
SchemaURL: schemaURL,
69+
ID: id,
70+
InitialMembershipTeamID: &team.ID,
71+
Attributes: []*domain.CreateAttribute{emailAttr},
7272
}))
7373
}
7474

internal/bootstrap/users/import.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -74,18 +74,18 @@ func importFile(
7474
return err
7575
}
7676

77-
var teamID *string
77+
var participationTeamID *string
7878
if doc.Header.TeamID != "" {
7979
tid := doc.Header.TeamID
80-
teamID = &tid
80+
participationTeamID = &tid
8181
}
8282

8383
if err := userRepo.Create(ctx, pool, &domain.CreateUser{
84-
ProjectID: doc.Header.ProjectID,
85-
SchemaURL: doc.Header.SchemaURL,
86-
ID: doc.Header.ID,
87-
TeamID: teamID,
88-
Attributes: attrs,
84+
ProjectID: doc.Header.ProjectID,
85+
SchemaURL: doc.Header.SchemaURL,
86+
ID: doc.Header.ID,
87+
InitialMembershipTeamID: participationTeamID,
88+
Attributes: attrs,
8989
}); err != nil {
9090
return fmt.Errorf("create user: %w", err)
9191
}

internal/domain/mock/team.mock.go

Lines changed: 38 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)