From 3ff4fbc7e4f22cd6eee49d3de951350c5ed855cd Mon Sep 17 00:00:00 2001 From: Silvan <27845747+adlerhurst@users.noreply.github.com> Date: Fri, 18 Sep 2026 15:08:42 +0200 Subject: [PATCH] fix(eventstore): add owners to unique constraints (#12740) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Which Problems Are Solved Bulk unique-constraint deletes (org, IDP with many links, user, project) reconstruct every uniqueness key in Go and emit N `DELETE`s. That can OOM on large tenants. A one-shot owner backfill is also not enough: during a rolling deploy, older binaries still insert `owners = '{}'`, so a later owner-only delete would miss those rows. Owner-only org/user/grant delete only matches rows whose `owners` bag contains that tag. Several grant and member types stamped the aggregate resource owner, which is not always the user’s home org or the granted org. Username-scope rewrites also dropped `user:` tags, so owner-only user delete could miss rewritten username rows. Command must not switch to owner-only delete on the first 79 of a version, because leftover `'{}'` rows from rolling-update overlap are only backfilled on a later 79. A failed 79 must not turn owner-delete on, and backfill joins must not stamp the wrong org (unverified domains, org-scoped usernames colliding with instance-scoped ones). # How the Problems Are Solved Uniqueness stays the existing primary key `(instance_id, unique_type, unique_field)`. `owners` is only a lifecycle bag of tags (`org:`, `user:`, `idp:`, `project:`, `grant:`). - Add `owners TEXT[] NOT NULL DEFAULT '{}'` on `eventstore.unique_constraints` (init SQL for greenfield, setup 78 for existing clusters). - Create `unique_constraints_owners_gin` with `CREATE INDEX CONCURRENTLY` **outside a transaction** (same pattern as step 76). Built-in `GIN (owners)` only; no `btree_gin`. - Stamp `WithOwners` on **add** events. Field-level removes ignore owners. `RemoveByOwner` stores the tag in `Owners` and both v1/v3 and v2 pushers run `DELETE … WHERE instance_id = $1 AND owners @> ARRAY[$tag]::text[]`. - Setup 79 backfills `owners` from projections via numbered `cmd/setup/79/*.sql` files. Table names are interpolated from `internal/query/projection` constants. Matches use `unique_field IN (x, lower(x))` so the primary key can be used. Missing projection tables (`42P01`) are skipped. - Org domains are tagged only when `is_verified`. Instance-scoped usernames join only users that are not org-scoped (organization settings or domain policy `user_login_must_be_domain`). Every username row still gets `org:` + `user:` of that user. - 79 is a **repeatable** migration. `Check` re-runs when `lastRun["version"]` changed or when setup-only `ForceFinalize` is set and `lastRun["finalized"]` is still false. `Finalized` is written only after Execute succeeds. - Failed setup events keep the last successful `lastRun`. Command reads `finalized` only from `system.migration.repeatable.done`. - Org / user / project remove skip N-key event replay **when that `finalized` bit is true**. Otherwise they keep today’s N-key lists **and** still emit `RemoveByOwner`. The “ready” result is cached in memory once true. Setup writes `finalized` on the 79 repeatable.done payload: - First 79 of a version: backfill only, `finalized=false` (unless `ForceFinalize` is already set). - Later 79: version change or `BackfillUniqueConstraintOwners.ForceFinalize` / `ZITADEL_BACKFILLUNIQUECONSTRAINTOWNERS_FORCEFINALIZE` after the rolling update is done. That run backfills leftovers and writes `finalized=true`. Member and grant owner bags now include every org that should delete the row: - Instance, org, and project members also stamp `org:`. - User grants stamp grant, user, project, and granted orgs. - Project grant members stamp the project org, user org, and granted org. - Project grants stamp both orgs plus `grant:`. Grant remove emits `RemoveByOwner(grant)`. - Username-scope rewrites keep `user:` as well as `org:`. - User/grant orgs are carried as unexported add-event fields (not persisted JSON). `WithOwners` skips empty and duplicate tags. # Additional Changes - Table-driven owner-tag tests, including project grant (two `org:` tags plus `grant:`), cross-org members, user grants, and username-scope rewrite `user:` tags. - Table-driven 79 `Check` + `finalizeAfterSuccess` tests for version change, sequential version bumps, ForceFinalize restart, and already-finalized skip. - Command tests: a failed 79 event with `finalized=true` does not enable owner-delete. - Integration fixtures (verified vs unverified org domain; instance-scoped `bob` vs org-scoped `bob`). - Instance-only and global unique types stay untagged (`'{}'`). Instance wipe still uses `UniqueConstraintInstanceRemove`. - Pusher owner cases live in `eventstore_pusher_owners_test.go` so `eventstore_pusher_test.go` stays under 1k lines. # Additional Context A later cleanup can delete the N-key replay helpers once a release that ran 79 a second time (or an operator who set `ForceFinalize`) is in production. That is not required for correctness.
Open in Web Open in Cursor 
--------- Co-authored-by: Cursor Agent Co-authored-by: Silvan --- .../sql/10_unique_constraints_table.sql | 3 +- cmd/mirror/event_store.go | 4 +- cmd/setup/78.go | 35 +++ cmd/setup/78_owners_column.sql | 1 + cmd/setup/78_owners_gin.sql | 1 + cmd/setup/78_test.go | 28 ++ cmd/setup/79.go | 139 +++++++++ cmd/setup/79/01_usernames_org_scoped.sql | 8 + cmd/setup/79/02_usernames.sql | 16 + cmd/setup/79/03_external_idps.sql | 8 + cmd/setup/79/04_org_name.sql | 8 + cmd/setup/79/05_org_domain.sql | 9 + cmd/setup/79/06_project_names.sql | 8 + cmd/setup/79/07_appname.sql | 8 + cmd/setup/79/08_project_role.sql | 8 + cmd/setup/79/09_entity_ids.sql | 9 + cmd/setup/79/10_project_grant.sql | 9 + cmd/setup/79/11_project_grant_member.sql | 11 + cmd/setup/79/12_user_grant.sql | 11 + cmd/setup/79/13_member_org.sql | 9 + cmd/setup/79/14_member_project.sql | 9 + cmd/setup/79/15_member_instance.sql | 9 + cmd/setup/79/16_group_name.sql | 8 + cmd/setup/79/17_action_names.sql | 8 + cmd/setup/79/18_idp_config_names.sql | 8 + .../79/19_idp_config_names_templates.sql | 8 + cmd/setup/79/20_mail_text.sql | 9 + cmd/setup/79_test.go | 213 +++++++++++++ cmd/setup/config.go | 2 + .../unique_constraint_owners_test.go | 107 +++++++ cmd/setup/setup.go | 8 + cmd/setup/steps.yaml | 8 +- internal/command/command.go | 6 + internal/command/instance_member.go | 5 +- internal/command/instance_member_test.go | 16 +- internal/command/instance_test.go | 10 +- internal/command/org.go | 49 +-- internal/command/org_member.go | 5 +- internal/command/org_member_test.go | 6 +- internal/command/org_test.go | 31 +- internal/command/organization_settings.go | 9 +- .../command/organization_settings_model.go | 8 +- .../command/organization_settings_test.go | 16 +- internal/command/project.go | 33 +- internal/command/project_grant_member.go | 4 +- internal/command/project_grant_member_test.go | 4 +- internal/command/project_member.go | 4 +- internal/command/project_member_test.go | 4 +- internal/command/project_test.go | 27 ++ internal/command/unique_constraint_owners.go | 70 +++++ .../command/unique_constraint_owners_test.go | 165 ++++++++++ internal/command/user.go | 25 +- internal/command/user_grant.go | 64 ++-- internal/command/user_grant_model.go | 5 + internal/command/user_grant_test.go | 22 +- internal/command/user_test.go | 48 +++ internal/command/user_v2.go | 14 +- .../eventstore_pusher_owners_test.go | 285 ++++++++++++++++++ internal/eventstore/local_postgres_test.go | 38 ++- internal/eventstore/unique_constraint.go | 70 +++++ internal/eventstore/unique_constraint_test.go | 74 +++++ internal/eventstore/v3/unique_constraints.go | 12 +- .../eventstore/v3/unique_constraints_add.sql | 3 +- internal/migration/migration.go | 4 + internal/migration/should_exec_test.go | 74 +++++ internal/migration/step.go | 7 +- internal/migration/step_test.go | 29 ++ internal/repository/action/action.go | 4 +- internal/repository/group/group.go | 4 +- internal/repository/idpconfig/idp_config.go | 16 +- internal/repository/instance/member.go | 5 + internal/repository/member/events.go | 29 +- internal/repository/org/domain.go | 2 +- internal/repository/org/member.go | 5 + internal/repository/org/org.go | 5 +- .../organization_settings/organization.go | 18 +- internal/repository/policy/mail_text.go | 11 +- internal/repository/policy/policy_domain.go | 4 +- internal/repository/project/application.go | 4 +- internal/repository/project/grant.go | 7 +- internal/repository/project/grant_member.go | 22 +- internal/repository/project/member.go | 5 + internal/repository/project/project.go | 29 +- internal/repository/project/role.go | 2 +- internal/repository/project/saml_config.go | 4 +- .../unique_constraint_owners_test.go | 234 ++++++++++++++ internal/repository/user/human.go | 4 +- .../repository/user/human_external_idp.go | 2 +- internal/repository/user/machine.go | 2 +- internal/repository/user/user.go | 39 ++- internal/repository/usergrant/user_grant.go | 25 +- internal/v2/eventstore/postgres/push.go | 21 +- internal/v2/eventstore/postgres/push_test.go | 60 +++- internal/v2/eventstore/unique_constraint.go | 47 +++ .../v2/eventstore/unique_constraint_test.go | 32 ++ 95 files changed, 2411 insertions(+), 207 deletions(-) create mode 100644 cmd/setup/78.go create mode 100644 cmd/setup/78_owners_column.sql create mode 100644 cmd/setup/78_owners_gin.sql create mode 100644 cmd/setup/78_test.go create mode 100644 cmd/setup/79.go create mode 100644 cmd/setup/79/01_usernames_org_scoped.sql create mode 100644 cmd/setup/79/02_usernames.sql create mode 100644 cmd/setup/79/03_external_idps.sql create mode 100644 cmd/setup/79/04_org_name.sql create mode 100644 cmd/setup/79/05_org_domain.sql create mode 100644 cmd/setup/79/06_project_names.sql create mode 100644 cmd/setup/79/07_appname.sql create mode 100644 cmd/setup/79/08_project_role.sql create mode 100644 cmd/setup/79/09_entity_ids.sql create mode 100644 cmd/setup/79/10_project_grant.sql create mode 100644 cmd/setup/79/11_project_grant_member.sql create mode 100644 cmd/setup/79/12_user_grant.sql create mode 100644 cmd/setup/79/13_member_org.sql create mode 100644 cmd/setup/79/14_member_project.sql create mode 100644 cmd/setup/79/15_member_instance.sql create mode 100644 cmd/setup/79/16_group_name.sql create mode 100644 cmd/setup/79/17_action_names.sql create mode 100644 cmd/setup/79/18_idp_config_names.sql create mode 100644 cmd/setup/79/19_idp_config_names_templates.sql create mode 100644 cmd/setup/79/20_mail_text.sql create mode 100644 cmd/setup/79_test.go create mode 100644 cmd/setup/integration_test/unique_constraint_owners_test.go create mode 100644 internal/command/unique_constraint_owners.go create mode 100644 internal/command/unique_constraint_owners_test.go create mode 100644 internal/eventstore/eventstore_pusher_owners_test.go create mode 100644 internal/eventstore/unique_constraint_test.go create mode 100644 internal/migration/should_exec_test.go create mode 100644 internal/repository/unique_constraint_owners_test.go create mode 100644 internal/v2/eventstore/unique_constraint_test.go diff --git a/cmd/initialise/sql/10_unique_constraints_table.sql b/cmd/initialise/sql/10_unique_constraints_table.sql index 61626d58c..eb7f8f165 100644 --- a/cmd/initialise/sql/10_unique_constraints_table.sql +++ b/cmd/initialise/sql/10_unique_constraints_table.sql @@ -2,5 +2,6 @@ CREATE TABLE IF NOT EXISTS eventstore.unique_constraints ( instance_id TEXT, unique_type TEXT, unique_field TEXT, + owners TEXT[] NOT NULL DEFAULT '{}', PRIMARY KEY (instance_id, unique_type, unique_field) -); \ No newline at end of file +); diff --git a/cmd/mirror/event_store.go b/cmd/mirror/event_store.go index 6cb040350..9899ed268 100644 --- a/cmd/mirror/event_store.go +++ b/cmd/mirror/event_store.go @@ -239,7 +239,7 @@ func copyUniqueConstraints(ctx context.Context, source, dest *db.DB) { err := sourceConn.Raw(func(driverConn interface{}) error { conn := driverConn.(*stdlib.Conn).Conn() var stmt database.Statement - stmt.WriteString("COPY (SELECT instance_id, unique_type, unique_field FROM eventstore.unique_constraints ") + stmt.WriteString("COPY (SELECT instance_id, unique_type, unique_field, owners FROM eventstore.unique_constraints ") stmt.WriteString(instanceClause()) stmt.WriteString(") TO stdout") @@ -268,7 +268,7 @@ func copyUniqueConstraints(ctx context.Context, source, dest *db.DB) { } } - tag, err := conn.PgConn().CopyFrom(ctx, reader, "COPY eventstore.unique_constraints FROM stdin") + tag, err := conn.PgConn().CopyFrom(ctx, reader, "COPY eventstore.unique_constraints (instance_id, unique_type, unique_field, owners) FROM stdin") eventCount = tag.RowsAffected() return err diff --git a/cmd/setup/78.go b/cmd/setup/78.go new file mode 100644 index 000000000..3d06972da --- /dev/null +++ b/cmd/setup/78.go @@ -0,0 +1,35 @@ +package setup + +import ( + "context" + _ "embed" + + "github.com/zitadel/zitadel/backend/v3/instrumentation/logging" + "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/eventstore" +) + +var ( + //go:embed 78_owners_column.sql + uniqueConstraintsOwnersColumn string + //go:embed 78_owners_gin.sql + uniqueConstraintsOwnersGIN string +) + +type UniqueConstraintOwners struct { + dbClient *database.DB +} + +func (mig *UniqueConstraintOwners) Execute(ctx context.Context, _ eventstore.Event) error { + logging.Info(ctx, "add unique constraint owners column", "migration", mig.String()) + if _, err := mig.dbClient.ExecContext(ctx, uniqueConstraintsOwnersColumn); err != nil { + return err + } + logging.Info(ctx, "create unique constraint owners gin index concurrently", "migration", mig.String()) + _, err := mig.dbClient.ExecContext(ctx, uniqueConstraintsOwnersGIN) + return err +} + +func (mig *UniqueConstraintOwners) String() string { + return "78_unique_constraint_owners" +} diff --git a/cmd/setup/78_owners_column.sql b/cmd/setup/78_owners_column.sql new file mode 100644 index 000000000..129880bdf --- /dev/null +++ b/cmd/setup/78_owners_column.sql @@ -0,0 +1 @@ +ALTER TABLE eventstore.unique_constraints ADD COLUMN IF NOT EXISTS owners TEXT[] NOT NULL DEFAULT '{}'; diff --git a/cmd/setup/78_owners_gin.sql b/cmd/setup/78_owners_gin.sql new file mode 100644 index 000000000..715d39afd --- /dev/null +++ b/cmd/setup/78_owners_gin.sql @@ -0,0 +1 @@ +CREATE INDEX CONCURRENTLY IF NOT EXISTS unique_constraints_owners_gin ON eventstore.unique_constraints USING GIN (owners); diff --git a/cmd/setup/78_test.go b/cmd/setup/78_test.go new file mode 100644 index 000000000..529297e50 --- /dev/null +++ b/cmd/setup/78_test.go @@ -0,0 +1,28 @@ +package setup + +import ( + "context" + "regexp" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/database" +) + +func TestUniqueConstraintOwners_Execute(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer db.Close() + + mock.ExpectExec(regexp.QuoteMeta(uniqueConstraintsOwnersColumn)). + WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec(regexp.QuoteMeta(uniqueConstraintsOwnersGIN)). + WillReturnResult(sqlmock.NewResult(0, 0)) + + mig := &UniqueConstraintOwners{dbClient: &database.DB{DB: db}} + assert.NoError(t, mig.Execute(context.Background(), nil)) + assert.NoError(t, mock.ExpectationsWereMet()) +} diff --git a/cmd/setup/79.go b/cmd/setup/79.go new file mode 100644 index 000000000..c7a5095de --- /dev/null +++ b/cmd/setup/79.go @@ -0,0 +1,139 @@ +package setup + +import ( + "context" + "database/sql" + "embed" + "errors" + "strings" + + "github.com/jackc/pgx/v5/pgconn" + + "github.com/zitadel/zitadel/backend/v3/instrumentation/logging" + "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/query/projection" +) + +var ( + //go:embed 79/*.sql + backfillUniqueConstraintOwnersFS embed.FS +) + +type BackfillUniqueConstraintOwners struct { + dbClient *database.DB + + Version string `json:"version"` + Finalized bool `json:"finalized"` + ForceFinalize bool `json:"-"` // YAML/env only; not persisted on lastRun + + lastVersion string `json:"-"` + lastFinalized bool `json:"-"` +} + +func (mig *BackfillUniqueConstraintOwners) Check(lastRun map[string]interface{}) bool { + if lastRun == nil { + lastRun = map[string]interface{}{} + } + mig.lastVersion, _ = lastRun["version"].(string) + mig.lastFinalized, _ = lastRun["finalized"].(bool) + + versionChanged := mig.lastVersion != mig.Version + if mig.lastVersion == "" { + return true + } + return versionChanged || (mig.ForceFinalize && !mig.lastFinalized) +} + +func (mig *BackfillUniqueConstraintOwners) finalizeAfterSuccess() { + mig.Finalized = mig.lastFinalized || mig.ForceFinalize || (mig.lastVersion != "" && mig.lastVersion != mig.Version) +} + +func (mig *BackfillUniqueConstraintOwners) Execute(ctx context.Context, _ eventstore.Event) error { + statements, err := backfillUniqueConstraintOwnersStatements() + if err != nil { + return err + } + for _, stmt := range statements { + logging.Info(ctx, "backfill unique constraint owners", "file", stmt.file, "migration", mig.String()) + if _, err := mig.dbClient.ExecContext(ctx, stmt.query); err != nil { + if isUndefinedTable(err) { + logging.Info(ctx, "skip unique constraint owners backfill, relation missing", "file", stmt.file, "migration", mig.String()) + continue + } + return err + } + } + + var unmatched int64 + err = mig.dbClient.QueryRowContext(ctx, func(row *sql.Row) error { + return row.Scan(&unmatched) + }, `SELECT COUNT(*) FROM eventstore.unique_constraints WHERE unique_type = ANY($1) AND owners = '{}'`, + database.TextArray[string](eventstore.UniqueTypesWithOwners)) + if err != nil { + return err + } + mig.finalizeAfterSuccess() + logging.Info(ctx, "unique constraint owners backfill complete", "unmatched", unmatched, "finalized", mig.Finalized, "migration", mig.String()) + return nil +} + +func (mig *BackfillUniqueConstraintOwners) String() string { + return eventstore.UniqueConstraintOwnersBackfillStep +} + +func isUndefinedTable(err error) bool { + var pgErr *pgconn.PgError + return errors.As(err, &pgErr) && pgErr.Code == "42P01" +} + +func interpolateBackfillSQL(query string) string { + return strings.NewReplacer( + "{{.users}}", projection.UserTable, + "{{.organization_settings}}", projection.OrganizationSettingsTable, + "{{.domain_policies}}", projection.DomainPolicyTable, + "{{.idp_user_links}}", projection.IDPUserLinkTable, + "{{.orgs}}", projection.OrgProjectionTable, + "{{.org_domains}}", projection.OrgDomainTable, + "{{.projects}}", projection.ProjectProjectionTable, + "{{.apps}}", projection.AppProjectionTable, + "{{.apps_saml}}", projection.AppSAMLTable, + "{{.project_roles}}", projection.ProjectRoleProjectionTable, + "{{.project_grants}}", projection.ProjectGrantProjectionTable, + "{{.project_grant_members}}", projection.ProjectGrantMemberProjectionTable, + "{{.user_grants}}", projection.UserGrantProjectionTable, + "{{.org_members}}", projection.OrgMemberProjectionTable, + "{{.project_members}}", projection.ProjectMemberProjectionTable, + "{{.instance_members}}", projection.InstanceMemberProjectionTable, + "{{.groups}}", projection.GroupProjectionTable, + "{{.actions}}", projection.ActionTable, + "{{.idps}}", projection.IDPTable, + "{{.idp_templates}}", projection.IDPTemplateTable, + "{{.message_texts}}", projection.MessageTextTable, + ).Replace(query) +} + +func backfillUniqueConstraintOwnersStatements() ([]statement, error) { + statements, err := readStatements(backfillUniqueConstraintOwnersFS, "79") + if err != nil { + return nil, err + } + for i := range statements { + statements[i].query = interpolateBackfillSQL(statements[i].query) + } + return statements, nil +} + +// BackfillUniqueConstraintOwnersQuery returns the interpolated SQL for a setup 79 file. +func BackfillUniqueConstraintOwnersQuery(file string) (string, error) { + statements, err := backfillUniqueConstraintOwnersStatements() + if err != nil { + return "", err + } + for _, stmt := range statements { + if stmt.file == file { + return stmt.query, nil + } + } + return "", errors.New("unknown backfill statement: " + file) +} diff --git a/cmd/setup/79/01_usernames_org_scoped.sql b/cmd/setup/79/01_usernames_org_scoped.sql new file mode 100644 index 000000000..1bbc728a7 --- /dev/null +++ b/cmd/setup/79/01_usernames_org_scoped.sql @@ -0,0 +1,8 @@ +-- unique_field: username+resourceOwner (user.NewAddUsernameUniqueConstraint, org-scoped) +UPDATE eventstore.unique_constraints uc +SET owners = ARRAY['org:' || u.resource_owner, 'user:' || u.id] +FROM {{.users}} u +WHERE uc.unique_type = 'usernames' + AND uc.instance_id = u.instance_id + AND uc.owners = '{}' + AND uc.unique_field IN (u.username || u.resource_owner, lower(u.username || u.resource_owner)); diff --git a/cmd/setup/79/02_usernames.sql b/cmd/setup/79/02_usernames.sql new file mode 100644 index 000000000..bbe37835b --- /dev/null +++ b/cmd/setup/79/02_usernames.sql @@ -0,0 +1,16 @@ +-- unique_field: username (user.NewAddUsernameUniqueConstraint, instance-scoped) +UPDATE eventstore.unique_constraints uc +SET owners = ARRAY['org:' || u.resource_owner, 'user:' || u.id] +FROM {{.users}} u +LEFT JOIN {{.organization_settings}} s + ON s.instance_id = u.instance_id AND s.id = u.resource_owner +LEFT JOIN {{.domain_policies}} org_pol + ON org_pol.instance_id = u.instance_id AND org_pol.id = u.resource_owner +LEFT JOIN {{.domain_policies}} inst_pol + ON inst_pol.instance_id = u.instance_id AND inst_pol.is_default +WHERE uc.unique_type = 'usernames' + AND uc.instance_id = u.instance_id + AND uc.owners = '{}' + AND uc.unique_field IN (u.username, lower(u.username)) + AND NOT COALESCE(s.organization_scoped_usernames, false) + AND NOT COALESCE(org_pol.user_login_must_be_domain, inst_pol.user_login_must_be_domain, false); diff --git a/cmd/setup/79/03_external_idps.sql b/cmd/setup/79/03_external_idps.sql new file mode 100644 index 000000000..5a8c39e26 --- /dev/null +++ b/cmd/setup/79/03_external_idps.sql @@ -0,0 +1,8 @@ +-- unique_field: idpConfigID+externalUserID (user.NewAddUserIDPLinkUniqueConstraint) +UPDATE eventstore.unique_constraints uc +SET owners = ARRAY['org:' || l.resource_owner, 'idp:' || l.idp_id, 'user:' || l.user_id] +FROM {{.idp_user_links}} l +WHERE uc.unique_type = 'external_idps' + AND uc.instance_id = l.instance_id + AND uc.owners = '{}' + AND uc.unique_field IN (l.idp_id || l.external_user_id, lower(l.idp_id || l.external_user_id)); diff --git a/cmd/setup/79/04_org_name.sql b/cmd/setup/79/04_org_name.sql new file mode 100644 index 000000000..baef09e28 --- /dev/null +++ b/cmd/setup/79/04_org_name.sql @@ -0,0 +1,8 @@ +-- unique_field: org name (org.NewAddOrgNameUniqueConstraint) +UPDATE eventstore.unique_constraints uc +SET owners = ARRAY['org:' || o.id] +FROM {{.orgs}} o +WHERE uc.unique_type = 'org_name' + AND uc.instance_id = o.instance_id + AND uc.owners = '{}' + AND uc.unique_field IN (o.name, lower(o.name)); diff --git a/cmd/setup/79/05_org_domain.sql b/cmd/setup/79/05_org_domain.sql new file mode 100644 index 000000000..997eed8d0 --- /dev/null +++ b/cmd/setup/79/05_org_domain.sql @@ -0,0 +1,9 @@ +-- unique_field: domain (org.NewAddOrgDomainUniqueConstraint) +UPDATE eventstore.unique_constraints uc +SET owners = ARRAY['org:' || d.org_id] +FROM {{.org_domains}} d +WHERE uc.unique_type = 'org_domain' + AND uc.instance_id = d.instance_id + AND uc.owners = '{}' + AND d.is_verified + AND uc.unique_field IN (d.domain, lower(d.domain)); diff --git a/cmd/setup/79/06_project_names.sql b/cmd/setup/79/06_project_names.sql new file mode 100644 index 000000000..ce7a49dd2 --- /dev/null +++ b/cmd/setup/79/06_project_names.sql @@ -0,0 +1,8 @@ +-- unique_field: name+resourceOwner (project.NewAddProjectNameUniqueConstraint) +UPDATE eventstore.unique_constraints uc +SET owners = ARRAY['org:' || p.resource_owner, 'project:' || p.id] +FROM {{.projects}} p +WHERE uc.unique_type = 'project_names' + AND uc.instance_id = p.instance_id + AND uc.owners = '{}' + AND uc.unique_field IN (p.name || p.resource_owner, lower(p.name || p.resource_owner)); diff --git a/cmd/setup/79/07_appname.sql b/cmd/setup/79/07_appname.sql new file mode 100644 index 000000000..a4d7b9265 --- /dev/null +++ b/cmd/setup/79/07_appname.sql @@ -0,0 +1,8 @@ +-- unique_field: name:projectID (project.NewAddApplicationUniqueConstraint) +UPDATE eventstore.unique_constraints uc +SET owners = ARRAY['org:' || a.resource_owner, 'project:' || a.project_id] +FROM {{.apps}} a +WHERE uc.unique_type = 'appname' + AND uc.instance_id = a.instance_id + AND uc.owners = '{}' + AND uc.unique_field IN (a.name || ':' || a.project_id, lower(a.name || ':' || a.project_id)); diff --git a/cmd/setup/79/08_project_role.sql b/cmd/setup/79/08_project_role.sql new file mode 100644 index 000000000..394d752ad --- /dev/null +++ b/cmd/setup/79/08_project_role.sql @@ -0,0 +1,8 @@ +-- unique_field: roleKey:projectID (project.NewAddProjectRoleUniqueConstraint) +UPDATE eventstore.unique_constraints uc +SET owners = ARRAY['org:' || r.resource_owner, 'project:' || r.project_id] +FROM {{.project_roles}} r +WHERE uc.unique_type = 'project_role' + AND uc.instance_id = r.instance_id + AND uc.owners = '{}' + AND uc.unique_field IN (r.role_key || ':' || r.project_id, lower(r.role_key || ':' || r.project_id)); diff --git a/cmd/setup/79/09_entity_ids.sql b/cmd/setup/79/09_entity_ids.sql new file mode 100644 index 000000000..cd741bd13 --- /dev/null +++ b/cmd/setup/79/09_entity_ids.sql @@ -0,0 +1,9 @@ +-- unique_field: entityID (project.NewAddSAMLConfigEntityIDUniqueConstraint) +UPDATE eventstore.unique_constraints uc +SET owners = ARRAY['org:' || a.resource_owner, 'project:' || a.project_id] +FROM {{.apps}} a +JOIN {{.apps_saml}} s ON s.app_id = a.id AND s.instance_id = a.instance_id +WHERE uc.unique_type = 'entity_ids' + AND uc.instance_id = a.instance_id + AND uc.owners = '{}' + AND uc.unique_field IN (s.entity_id, lower(s.entity_id)); diff --git a/cmd/setup/79/10_project_grant.sql b/cmd/setup/79/10_project_grant.sql new file mode 100644 index 000000000..e08a8d1ab --- /dev/null +++ b/cmd/setup/79/10_project_grant.sql @@ -0,0 +1,9 @@ +-- unique_field: grantedOrgID:projectID (project.NewAddProjectGrantUniqueConstraint) +-- resource_owner is the project org and granted_org_id is the granted org; they differ +UPDATE eventstore.unique_constraints uc +SET owners = ARRAY['org:' || g.resource_owner, 'org:' || g.granted_org_id, 'project:' || g.project_id, 'grant:' || g.grant_id] +FROM {{.project_grants}} g +WHERE uc.unique_type = 'project_grant' + AND uc.instance_id = g.instance_id + AND uc.owners = '{}' + AND uc.unique_field IN (g.granted_org_id || ':' || g.project_id, lower(g.granted_org_id || ':' || g.project_id)); diff --git a/cmd/setup/79/11_project_grant_member.sql b/cmd/setup/79/11_project_grant_member.sql new file mode 100644 index 000000000..c75007af0 --- /dev/null +++ b/cmd/setup/79/11_project_grant_member.sql @@ -0,0 +1,11 @@ +-- unique_field: projectID:userID:grantID (project.NewAddProjectGrantMemberUniqueConstraint) +-- resource_owner is the project org; also stamp the user's home org and the granted org +UPDATE eventstore.unique_constraints uc +SET owners = ARRAY['org:' || m.resource_owner, 'org:' || m.user_resource_owner, 'user:' || m.user_id, 'project:' || m.project_id] + || CASE WHEN COALESCE(m.granted_org, '') <> '' THEN ARRAY['org:' || m.granted_org] ELSE ARRAY[]::text[] END + || CASE WHEN COALESCE(m.grant_id, '') <> '' THEN ARRAY['grant:' || m.grant_id] ELSE ARRAY[]::text[] END +FROM {{.project_grant_members}} m +WHERE uc.unique_type = 'project_grant_member' + AND uc.instance_id = m.instance_id + AND uc.owners = '{}' + AND uc.unique_field IN (m.project_id || ':' || m.user_id || ':' || m.grant_id, lower(m.project_id || ':' || m.user_id || ':' || m.grant_id)); diff --git a/cmd/setup/79/12_user_grant.sql b/cmd/setup/79/12_user_grant.sql new file mode 100644 index 000000000..1e47f329f --- /dev/null +++ b/cmd/setup/79/12_user_grant.sql @@ -0,0 +1,11 @@ +-- unique_field: resourceOwner:userID:projectID:grantID (usergrant.NewAddUserGrantUniqueConstraint) +-- grant resource_owner is not always the user's org; stamp user, project, and granted orgs +UPDATE eventstore.unique_constraints uc +SET owners = ARRAY['org:' || g.resource_owner, 'org:' || g.resource_owner_user, 'org:' || g.resource_owner_project, 'user:' || g.user_id, 'project:' || g.project_id] + || CASE WHEN COALESCE(g.granted_org, '') <> '' THEN ARRAY['org:' || g.granted_org] ELSE ARRAY[]::text[] END + || CASE WHEN COALESCE(g.grant_id, '') <> '' THEN ARRAY['grant:' || g.grant_id] ELSE ARRAY[]::text[] END +FROM {{.user_grants}} g +WHERE uc.unique_type = 'user_grant' + AND uc.instance_id = g.instance_id + AND uc.owners = '{}' + AND uc.unique_field IN (g.resource_owner || ':' || g.user_id || ':' || g.project_id || ':' || g.grant_id, lower(g.resource_owner || ':' || g.user_id || ':' || g.project_id || ':' || g.grant_id)); diff --git a/cmd/setup/79/13_member_org.sql b/cmd/setup/79/13_member_org.sql new file mode 100644 index 000000000..a3c143c12 --- /dev/null +++ b/cmd/setup/79/13_member_org.sql @@ -0,0 +1,9 @@ +-- unique_field: orgID:userID (member.NewAddMemberUniqueConstraint, org aggregate) +-- stamp both the membership org and the user's home org (they differ for cross-org members) +UPDATE eventstore.unique_constraints uc +SET owners = ARRAY['org:' || m.org_id, 'org:' || m.user_resource_owner, 'user:' || m.user_id] +FROM {{.org_members}} m +WHERE uc.unique_type = 'member' + AND uc.instance_id = m.instance_id + AND uc.owners = '{}' + AND uc.unique_field IN (m.org_id || ':' || m.user_id, lower(m.org_id || ':' || m.user_id)); diff --git a/cmd/setup/79/14_member_project.sql b/cmd/setup/79/14_member_project.sql new file mode 100644 index 000000000..1dce4c8d4 --- /dev/null +++ b/cmd/setup/79/14_member_project.sql @@ -0,0 +1,9 @@ +-- unique_field: projectID:userID (member.NewAddMemberUniqueConstraint, project aggregate) +-- stamp the project org and the user's home org (they differ for cross-org members) +UPDATE eventstore.unique_constraints uc +SET owners = ARRAY['org:' || m.resource_owner, 'org:' || m.user_resource_owner, 'user:' || m.user_id, 'project:' || m.project_id] +FROM {{.project_members}} m +WHERE uc.unique_type = 'member' + AND uc.instance_id = m.instance_id + AND uc.owners = '{}' + AND uc.unique_field IN (m.project_id || ':' || m.user_id, lower(m.project_id || ':' || m.user_id)); diff --git a/cmd/setup/79/15_member_instance.sql b/cmd/setup/79/15_member_instance.sql new file mode 100644 index 000000000..f2625e93f --- /dev/null +++ b/cmd/setup/79/15_member_instance.sql @@ -0,0 +1,9 @@ +-- unique_field: instanceID:userID (member.NewAddMemberUniqueConstraint, instance aggregate) +-- instance members live on the instance aggregate, so also stamp the user's home org +UPDATE eventstore.unique_constraints uc +SET owners = ARRAY['user:' || m.user_id, 'org:' || m.user_resource_owner] +FROM {{.instance_members}} m +WHERE uc.unique_type = 'member' + AND uc.instance_id = m.instance_id + AND uc.owners = '{}' + AND uc.unique_field IN (m.id || ':' || m.user_id, lower(m.id || ':' || m.user_id)); diff --git a/cmd/setup/79/16_group_name.sql b/cmd/setup/79/16_group_name.sql new file mode 100644 index 000000000..2e317e361 --- /dev/null +++ b/cmd/setup/79/16_group_name.sql @@ -0,0 +1,8 @@ +-- unique_field: name:resourceOwner (group.NewAddGroupNameUniqueConstraint) +UPDATE eventstore.unique_constraints uc +SET owners = ARRAY['org:' || g.resource_owner] +FROM {{.groups}} g +WHERE uc.unique_type = 'group_name' + AND uc.instance_id = g.instance_id + AND uc.owners = '{}' + AND uc.unique_field IN (g.name || ':' || g.resource_owner, lower(g.name || ':' || g.resource_owner)); diff --git a/cmd/setup/79/17_action_names.sql b/cmd/setup/79/17_action_names.sql new file mode 100644 index 000000000..62ddb6327 --- /dev/null +++ b/cmd/setup/79/17_action_names.sql @@ -0,0 +1,8 @@ +-- unique_field: name:resourceOwner (action.NewAddActionNameUniqueConstraint) +UPDATE eventstore.unique_constraints uc +SET owners = ARRAY['org:' || a.resource_owner] +FROM {{.actions}} a +WHERE uc.unique_type = 'action_names' + AND uc.instance_id = a.instance_id + AND uc.owners = '{}' + AND uc.unique_field IN (a.name || ':' || a.resource_owner, lower(a.name || ':' || a.resource_owner)); diff --git a/cmd/setup/79/18_idp_config_names.sql b/cmd/setup/79/18_idp_config_names.sql new file mode 100644 index 000000000..3b35e5bc1 --- /dev/null +++ b/cmd/setup/79/18_idp_config_names.sql @@ -0,0 +1,8 @@ +-- unique_field: name+resourceOwner (idpconfig.NewAddIDPConfigNameUniqueConstraint) +UPDATE eventstore.unique_constraints uc +SET owners = ARRAY['org:' || i.resource_owner, 'idp:' || i.id] +FROM {{.idps}} i +WHERE uc.unique_type = 'idp_config_names' + AND uc.instance_id = i.instance_id + AND uc.owners = '{}' + AND uc.unique_field IN (i.name || i.resource_owner, lower(i.name || i.resource_owner)); diff --git a/cmd/setup/79/19_idp_config_names_templates.sql b/cmd/setup/79/19_idp_config_names_templates.sql new file mode 100644 index 000000000..f1cae9dda --- /dev/null +++ b/cmd/setup/79/19_idp_config_names_templates.sql @@ -0,0 +1,8 @@ +-- unique_field: name+resourceOwner (idpconfig.NewAddIDPConfigNameUniqueConstraint, templates) +UPDATE eventstore.unique_constraints uc +SET owners = ARRAY['org:' || t.resource_owner, 'idp:' || t.id] +FROM {{.idp_templates}} t +WHERE uc.unique_type = 'idp_config_names' + AND uc.instance_id = t.instance_id + AND uc.owners = '{}' + AND uc.unique_field IN (t.name || t.resource_owner, lower(t.name || t.resource_owner)); diff --git a/cmd/setup/79/20_mail_text.sql b/cmd/setup/79/20_mail_text.sql new file mode 100644 index 000000000..9725c85c7 --- /dev/null +++ b/cmd/setup/79/20_mail_text.sql @@ -0,0 +1,9 @@ +-- unique_field: aggregateID:mailTextType:language (policy.NewAddMailTextUniqueConstraint) +UPDATE eventstore.unique_constraints uc +SET owners = ARRAY['org:' || t.aggregate_id] +FROM {{.message_texts}} t +WHERE uc.unique_type = 'mail_text' + AND uc.instance_id = t.instance_id + AND uc.owners = '{}' + AND t.aggregate_id <> t.instance_id + AND uc.unique_field IN (t.aggregate_id || ':' || t.type || ':' || t.language, lower(t.aggregate_id || ':' || t.type || ':' || t.language)); diff --git a/cmd/setup/79_test.go b/cmd/setup/79_test.go new file mode 100644 index 000000000..52b5c85e3 --- /dev/null +++ b/cmd/setup/79_test.go @@ -0,0 +1,213 @@ +package setup + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/migration" + "github.com/zitadel/zitadel/internal/query/projection" +) + +var _ migration.RepeatableMigration = (*BackfillUniqueConstraintOwners)(nil) + +func TestBackfillUniqueConstraintOwnersStmts(t *testing.T) { + statements, err := backfillUniqueConstraintOwnersStatements() + require.NoError(t, err) + + want := []struct { + file string + uniqueType string + field string + table string + extra []string + }{ + {"01_usernames_org_scoped.sql", "usernames", "u.username || u.resource_owner", projection.UserTable, []string{"'org:' || u.resource_owner"}}, + {"02_usernames.sql", "usernames", "u.username, lower(u.username)", projection.UserTable, []string{"'org:' || u.resource_owner", "organization_scoped_usernames", "user_login_must_be_domain"}}, + {"03_external_idps.sql", "external_idps", "l.idp_id || l.external_user_id", projection.IDPUserLinkTable, []string{"'idp:' || l.idp_id"}}, + {"04_org_name.sql", "org_name", "o.name", projection.OrgProjectionTable, nil}, + {"05_org_domain.sql", "org_domain", "d.domain", projection.OrgDomainTable, []string{"d.is_verified"}}, + {"06_project_names.sql", "project_names", "p.name || p.resource_owner", projection.ProjectProjectionTable, nil}, + {"07_appname.sql", "appname", "a.name || ':' || a.project_id", projection.AppProjectionTable, nil}, + {"08_project_role.sql", "project_role", "r.role_key || ':' || r.project_id", projection.ProjectRoleProjectionTable, nil}, + {"09_entity_ids.sql", "entity_ids", "s.entity_id", projection.AppProjectionTable, []string{projection.AppSAMLTable}}, + {"10_project_grant.sql", "project_grant", "g.granted_org_id || ':' || g.project_id", projection.ProjectGrantProjectionTable, []string{"'org:' || g.granted_org_id", "'grant:' || g.grant_id"}}, + {"11_project_grant_member.sql", "project_grant_member", "m.project_id || ':' || m.user_id || ':' || m.grant_id", projection.ProjectGrantMemberProjectionTable, []string{"'grant:' || m.grant_id", "m.user_resource_owner", "m.granted_org"}}, + {"12_user_grant.sql", "user_grant", "g.resource_owner || ':' || g.user_id || ':' || g.project_id || ':' || g.grant_id", projection.UserGrantProjectionTable, []string{"'grant:' || g.grant_id", "g.resource_owner_user", "g.granted_org"}}, + {"13_member_org.sql", "member", "m.org_id || ':' || m.user_id", projection.OrgMemberProjectionTable, []string{"m.user_resource_owner"}}, + {"14_member_project.sql", "member", "m.project_id || ':' || m.user_id", projection.ProjectMemberProjectionTable, []string{"m.user_resource_owner"}}, + {"15_member_instance.sql", "member", "m.id || ':' || m.user_id", projection.InstanceMemberProjectionTable, []string{"m.user_resource_owner"}}, + {"16_group_name.sql", "group_name", "g.name || ':' || g.resource_owner", projection.GroupProjectionTable, nil}, + {"17_action_names.sql", "action_names", "a.name || ':' || a.resource_owner", projection.ActionTable, nil}, + {"18_idp_config_names.sql", "idp_config_names", "i.name || i.resource_owner", projection.IDPTable, []string{"'idp:' || i.id"}}, + {"19_idp_config_names_templates.sql", "idp_config_names", "t.name || t.resource_owner", projection.IDPTemplateTable, []string{"'idp:' || t.id"}}, + {"20_mail_text.sql", "mail_text", "t.aggregate_id || ':' || t.type || ':' || t.language", projection.MessageTextTable, []string{"t.aggregate_id <> t.instance_id"}}, + } + + require.Len(t, statements, len(want)) + for i, stmt := range statements { + assert.Equal(t, want[i].file, stmt.file) + assert.Contains(t, stmt.query, "unique_type = '"+want[i].uniqueType+"'") + assert.Contains(t, stmt.query, want[i].field) + assert.Contains(t, stmt.query, want[i].table) + assert.Contains(t, stmt.query, "uc.unique_field IN (") + assert.NotContains(t, stmt.query, "lower(uc.unique_field)") + assert.NotContains(t, stmt.query, "{{.") + for _, extra := range want[i].extra { + assert.Contains(t, stmt.query, extra) + } + } +} + +func TestBackfillUniqueConstraintOwners_CheckAndFinalize(t *testing.T) { + tests := []struct { + name string + forceFinalize bool + lastRun map[string]interface{} + wantRun bool + wantFinalized bool + }{ + { + name: "missing lastRun runs without finalizing", + lastRun: nil, + wantRun: true, + wantFinalized: false, + }, + { + name: "empty lastRun runs without finalizing", + lastRun: map[string]interface{}{}, + wantRun: true, + wantFinalized: false, + }, + { + name: "missing lastRun with ForceFinalize finalizes", + forceFinalize: true, + lastRun: nil, + wantRun: true, + wantFinalized: true, + }, + { + name: "same version not finalized skips", + lastRun: map[string]interface{}{"version": "v2.0.0", "finalized": false}, + wantRun: false, + wantFinalized: false, + }, + { + name: "same version missing finalized skips", + lastRun: map[string]interface{}{"version": "v2.0.0"}, + wantRun: false, + wantFinalized: false, + }, + { + name: "same version not finalized with ForceFinalize runs and finalizes", + forceFinalize: true, + lastRun: map[string]interface{}{"version": "v2.0.0", "finalized": false}, + wantRun: true, + wantFinalized: true, + }, + { + name: "same version already finalized skips", + lastRun: map[string]interface{}{"version": "v2.0.0", "finalized": true}, + wantRun: false, + wantFinalized: true, + }, + { + name: "same version already finalized with ForceFinalize skips", + forceFinalize: true, + lastRun: map[string]interface{}{"version": "v2.0.0", "finalized": true}, + wantRun: false, + wantFinalized: true, + }, + { + name: "version changed runs and finalizes", + lastRun: map[string]interface{}{"version": "v1.0.0", "finalized": false}, + wantRun: true, + wantFinalized: true, + }, + { + name: "version changed keeps finalized true", + lastRun: map[string]interface{}{"version": "v1.0.0", "finalized": true}, + wantRun: true, + wantFinalized: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mig := &BackfillUniqueConstraintOwners{Version: "v2.0.0", ForceFinalize: tt.forceFinalize} + assert.Equal(t, tt.wantRun, mig.Check(tt.lastRun)) + assert.False(t, mig.Finalized, "Check must not set finalized before Execute") + mig.finalizeAfterSuccess() + assert.Equal(t, tt.wantFinalized, mig.Finalized) + }) + } +} + +func TestBackfillUniqueConstraintOwners_SequentialVersionChanges(t *testing.T) { + mig := &BackfillUniqueConstraintOwners{Version: "v1.0.0"} + require.True(t, mig.Check(nil)) + mig.finalizeAfterSuccess() + assert.False(t, mig.Finalized) + + lastRun := map[string]interface{}{"version": mig.Version, "finalized": mig.Finalized} + mig = &BackfillUniqueConstraintOwners{Version: "v2.0.0"} + require.True(t, mig.Check(lastRun)) + mig.finalizeAfterSuccess() + assert.True(t, mig.Finalized) + + lastRun = map[string]interface{}{"version": mig.Version, "finalized": mig.Finalized} + mig = &BackfillUniqueConstraintOwners{Version: "v3.0.0"} + require.True(t, mig.Check(lastRun)) + mig.finalizeAfterSuccess() + assert.True(t, mig.Finalized) +} + +func TestBackfillUniqueConstraintOwners_ForceFinalizeRestart(t *testing.T) { + mig := &BackfillUniqueConstraintOwners{Version: "v1.0.0"} + require.True(t, mig.Check(nil)) + mig.finalizeAfterSuccess() + require.False(t, mig.Finalized) + + lastRun := map[string]interface{}{"version": "v1.0.0", "finalized": false} + mig = &BackfillUniqueConstraintOwners{Version: "v1.0.0", ForceFinalize: true} + require.True(t, mig.Check(lastRun), "restart with ForceFinalize should re-run") + assert.False(t, mig.Finalized, "Check must not set finalized before Execute") + mig.finalizeAfterSuccess() + assert.True(t, mig.Finalized) + + lastRun = map[string]interface{}{"version": "v1.0.0", "finalized": true} + mig = &BackfillUniqueConstraintOwners{Version: "v1.0.0", ForceFinalize: true} + assert.False(t, mig.Check(lastRun), "later restart with ForceFinalize should skip") +} + +func TestUniqueTypesWithOwnersOmitsMailText(t *testing.T) { + assert.NotContains(t, eventstore.UniqueTypesWithOwners, "mail_text") + assert.Contains(t, eventstore.UniqueTypesWithOwners, "usernames") + assert.Contains(t, eventstore.UniqueTypesWithOwners, "idp_config_names") +} + +func TestBackfillUniqueConstraintOwnersJSONOmitsForceFinalize(t *testing.T) { + mig := &BackfillUniqueConstraintOwners{ + Version: "v2.0.0", + Finalized: true, + ForceFinalize: true, + lastVersion: "v1.0.0", + lastFinalized: true, + } + data, err := json.Marshal(mig) + require.NoError(t, err) + + var payload map[string]any + require.NoError(t, json.Unmarshal(data, &payload)) + assert.Equal(t, "v2.0.0", payload["version"]) + assert.Equal(t, true, payload["finalized"]) + assert.NotContains(t, payload, "ForceFinalize") + assert.NotContains(t, payload, "forceFinalize") + assert.NotContains(t, payload, "lastVersion") + assert.NotContains(t, payload, "lastFinalized") + assert.False(t, strings.Contains(string(data), "v1.0.0")) +} diff --git a/cmd/setup/config.go b/cmd/setup/config.go index 07a7d100c..1154368b8 100644 --- a/cmd/setup/config.go +++ b/cmd/setup/config.go @@ -197,6 +197,8 @@ type Steps struct { s75Apps7OIDCConfigsAddAppLinkConfig *Apps7OIDCConfigsAddAppLinkConfig s76Users14LoginEqualityIndexes *Users14LoginEqualityIndexes s77StampEventPositionAtInsert *StampEventPositionAtInsert + s78UniqueConstraintOwners *UniqueConstraintOwners + BackfillUniqueConstraintOwners *BackfillUniqueConstraintOwners RelationalTables *TransactionalTables } diff --git a/cmd/setup/integration_test/unique_constraint_owners_test.go b/cmd/setup/integration_test/unique_constraint_owners_test.go new file mode 100644 index 000000000..3b92bf163 --- /dev/null +++ b/cmd/setup/integration_test/unique_constraint_owners_test.go @@ -0,0 +1,107 @@ +//go:build integration + +package setup_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/cmd/setup" + "github.com/zitadel/zitadel/internal/query/projection" +) + +func TestBackfillUniqueConstraintOwners_OrgDomainVerifiedOnly(t *testing.T) { + instanceID := "uc-owners-domain-" + time.Now().Format("150405.000") + defer cleanupInstance(t, instanceID) + + _, err := dbPool.Exec(CTX, ` + INSERT INTO eventstore.unique_constraints (instance_id, unique_type, unique_field, owners) + VALUES ($1, 'org_domain', 'example.com', '{}')`, instanceID) + require.NoError(t, err) + + now := time.Now() + _, err = dbPool.Exec(CTX, ` + INSERT INTO `+projection.OrgDomainTable+` ( + creation_date, change_date, sequence, domain, org_id, instance_id, is_verified, is_primary, validation_type + ) VALUES + ($2, $2, 1, 'example.com', 'org-a', $1, true, true, 0), + ($2, $2, 1, 'example.com', 'org-b', $1, false, false, 0)`, instanceID, now) + require.NoError(t, err) + + query, err := setup.BackfillUniqueConstraintOwnersQuery("05_org_domain.sql") + require.NoError(t, err) + _, err = dbPool.Exec(CTX, query) + require.NoError(t, err) + + var owners []string + err = dbPool.QueryRow(CTX, ` + SELECT owners FROM eventstore.unique_constraints + WHERE instance_id = $1 AND unique_type = 'org_domain' AND unique_field = 'example.com'`, instanceID).Scan(&owners) + require.NoError(t, err) + assert.Equal(t, []string{"org:org-a"}, owners) +} + +func TestBackfillUniqueConstraintOwners_UsernameInstanceScopedOrg(t *testing.T) { + instanceID := "uc-owners-user-" + time.Now().Format("150405.000") + defer cleanupInstance(t, instanceID) + + now := time.Now() + _, err := dbPool.Exec(CTX, ` + INSERT INTO eventstore.unique_constraints (instance_id, unique_type, unique_field, owners) + VALUES + ($1, 'usernames', 'bob', '{}'), + ($1, 'usernames', 'boborg-y', '{}')`, instanceID) + require.NoError(t, err) + + _, err = dbPool.Exec(CTX, ` + INSERT INTO `+projection.UserTable+` ( + id, creation_date, change_date, resource_owner, instance_id, state, sequence, username, type + ) VALUES + ('user-x', $2, $2, 'org-x', $1, 1, 1, 'bob', 1), + ('user-y', $2, $2, 'org-y', $1, 1, 1, 'bob', 1)`, instanceID, now) + require.NoError(t, err) + + _, err = dbPool.Exec(CTX, ` + INSERT INTO `+projection.DomainPolicyTable+` ( + creation_date, change_date, sequence, id, state, user_login_must_be_domain, + validate_org_domains, smtp_sender_address_matches_instance_domain, is_default, resource_owner, instance_id + ) VALUES ($2, $2, 1, $1, 1, false, false, false, true, $1, $1)`, instanceID, now) + require.NoError(t, err) + + _, err = dbPool.Exec(CTX, ` + INSERT INTO `+projection.OrganizationSettingsTable+` ( + id, creation_date, change_date, resource_owner, instance_id, sequence, organization_scoped_usernames + ) VALUES ('org-y', $2, $2, 'org-y', $1, 1, true)`, instanceID, now) + require.NoError(t, err) + + query, err := setup.BackfillUniqueConstraintOwnersQuery("02_usernames.sql") + require.NoError(t, err) + _, err = dbPool.Exec(CTX, query) + require.NoError(t, err) + + var instanceOwners []string + err = dbPool.QueryRow(CTX, ` + SELECT owners FROM eventstore.unique_constraints + WHERE instance_id = $1 AND unique_type = 'usernames' AND unique_field = 'bob'`, instanceID).Scan(&instanceOwners) + require.NoError(t, err) + assert.ElementsMatch(t, []string{"org:org-x", "user:user-x"}, instanceOwners) + + var orgScopedOwners []string + err = dbPool.QueryRow(CTX, ` + SELECT owners FROM eventstore.unique_constraints + WHERE instance_id = $1 AND unique_type = 'usernames' AND unique_field = 'boborg-y'`, instanceID).Scan(&orgScopedOwners) + require.NoError(t, err) + assert.Empty(t, orgScopedOwners) +} + +func cleanupInstance(t *testing.T, instanceID string) { + t.Helper() + _, _ = dbPool.Exec(CTX, `DELETE FROM eventstore.unique_constraints WHERE instance_id = $1`, instanceID) + _, _ = dbPool.Exec(CTX, `DELETE FROM `+projection.OrgDomainTable+` WHERE instance_id = $1`, instanceID) + _, _ = dbPool.Exec(CTX, `DELETE FROM `+projection.UserTable+` WHERE instance_id = $1`, instanceID) + _, _ = dbPool.Exec(CTX, `DELETE FROM `+projection.DomainPolicyTable+` WHERE instance_id = $1`, instanceID) + _, _ = dbPool.Exec(CTX, `DELETE FROM `+projection.OrganizationSettingsTable+` WHERE instance_id = $1`, instanceID) +} diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index b3a82ec9a..5da178825 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -260,6 +260,12 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s75Apps7OIDCConfigsAddAppLinkConfig = &Apps7OIDCConfigsAddAppLinkConfig{dbClient: dbClient} steps.s76Users14LoginEqualityIndexes = &Users14LoginEqualityIndexes{dbClient: dbClient} steps.s77StampEventPositionAtInsert = &StampEventPositionAtInsert{dbClient: dbClient} + steps.s78UniqueConstraintOwners = &UniqueConstraintOwners{dbClient: dbClient} + if steps.BackfillUniqueConstraintOwners == nil { + steps.BackfillUniqueConstraintOwners = &BackfillUniqueConstraintOwners{} + } + steps.BackfillUniqueConstraintOwners.dbClient = dbClient + steps.BackfillUniqueConstraintOwners.Version = build.Version() err = projection.Create(ctx, dbClient, eventstoreClient, config.Projections, nil, nil, nil) if err != nil { @@ -320,6 +326,8 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s70AddEventStoreCommandEnforceOwner, steps.s76Users14LoginEqualityIndexes, steps.s77StampEventPositionAtInsert, + steps.s78UniqueConstraintOwners, + steps.BackfillUniqueConstraintOwners, } { setupErr = executeMigration(ctx, eventstoreClient, step, "migration failed") if setupErr != nil { diff --git a/cmd/setup/steps.yaml b/cmd/setup/steps.yaml index 2326d940d..0c081e911 100644 --- a/cmd/setup/steps.yaml +++ b/cmd/setup/steps.yaml @@ -184,4 +184,10 @@ FillFields: RelationalTables: # This is used for development DO NOT USE IN PRODUCTION - ShouldRecreateSchema: false # ZITADEL_RELATIONALTABLES_SHOULDRECREATESCHEMA \ No newline at end of file + ShouldRecreateSchema: false # ZITADEL_RELATIONALTABLES_SHOULDRECREATESCHEMA + +BackfillUniqueConstraintOwners: + # After a rolling update on this same version, set true and re-run setup to + # backfill leftover empty owners and enable owner-only unique-constraint delete. + # Default false: owner-delete waits until the next release runs 79 again. + ForceFinalize: false # ZITADEL_BACKFILLUNIQUECONSTRAINTOWNERS_FORCEFINALIZE \ No newline at end of file diff --git a/internal/command/command.go b/internal/command/command.go index 0988bcba4..84e746730 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -14,6 +14,7 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "time" "github.com/go-jose/go-jose/v4" @@ -109,6 +110,9 @@ type Commands struct { loginPaths LoginPaths ipLookupFunction internal_net.IPLookupFunc denyList []denylist.AddressChecker + + ownerDeleteReady func(context.Context) (bool, error) + ownerDeleteReadyCached atomic.Bool } //go:generate mockgen -package command -destination ./mock_login_paths.go . LoginPaths @@ -239,6 +243,8 @@ func StartCommands( denyList: denyList, } + repo.ownerDeleteReady = repo.uniqueConstraintOwnersBackfillFinalized + if defaultSecretGenerators != nil && defaultSecretGenerators.ClientSecret != nil { repo.newHashedSecret = newHashedSecretWithDefault(secretHasher, defaultSecretGenerators.ClientSecret) } diff --git a/internal/command/instance_member.go b/internal/command/instance_member.go index 81953bc26..b9db7605f 100644 --- a/internal/command/instance_member.go +++ b/internal/command/instance_member.go @@ -22,13 +22,14 @@ func (c *Commands) AddInstanceMemberCommand(a *instance.Aggregate, userID string return nil, zerrors.ThrowInvalidArgument(nil, "INSTANCE-4m0fS", "Errors.Instance.MemberInvalid") } return func(ctx context.Context, filter preparation.FilterToQueryReducer) ([]eventstore.Command, error) { - if exists, err := ExistsUser(ctx, filter, userID, "", false); err != nil || !exists { + userResourceOwner, exists, err := existingUser(ctx, filter, userID, "", false) + if err != nil || !exists { return nil, zerrors.ThrowPreconditionFailed(err, "INSTA-GSXOn", "Errors.User.NotFound") } if isMember, err := IsInstanceMember(ctx, filter, a.ID, userID); err != nil || isMember { return nil, zerrors.ThrowAlreadyExists(err, "INSTA-pFDwe", "Errors.Instance.Member.AlreadyExists") } - return []eventstore.Command{instance.NewMemberAddedEvent(ctx, &a.Aggregate, userID, roles...)}, nil + return []eventstore.Command{instance.NewMemberAddedEvent(ctx, &a.Aggregate, userID, roles...).WithUserResourceOwner(userResourceOwner)}, nil }, nil } diff --git a/internal/command/instance_member_test.go b/internal/command/instance_member_test.go index d3daa9e69..2d0f01375 100644 --- a/internal/command/instance_member_test.go +++ b/internal/command/instance_member_test.go @@ -161,7 +161,7 @@ func TestCommandSide_AddInstanceMember(t *testing.T) { &instance.NewAggregate("INSTANCE").Aggregate, "user1", []string{"IAM_OWNER"}..., - ), + ).WithUserResourceOwner("org1"), ), ), checkPermission: newMockPermissionCheckAllowed(), @@ -209,7 +209,7 @@ func TestCommandSide_AddInstanceMember(t *testing.T) { &instance.NewAggregate("INSTANCE").Aggregate, "user1", []string{"IAM_OWNER"}..., - ), + ).WithUserResourceOwner("org1"), ), ), checkPermission: newMockPermissionCheckAllowed(), @@ -339,7 +339,7 @@ func TestCommandSide_EnsureInstanceMemberRolesFromLogin(t *testing.T) { &instance.NewAggregate("INSTANCE").Aggregate, "user1", []string{"IAM_OWNER_VIEWER"}..., - ), + ).WithUserResourceOwner("org1"), ), ), checkPermission: newMockPermissionCheckNotAllowed(), @@ -614,7 +614,7 @@ func TestCommandSide_ChangeInstanceMember(t *testing.T) { &instance.NewAggregate("INSTANCE").Aggregate, "user1", []string{"IAM_OWNER"}..., - ), + ).WithUserResourceOwner("org1"), ), ), ), @@ -648,7 +648,7 @@ func TestCommandSide_ChangeInstanceMember(t *testing.T) { &instance.NewAggregate("INSTANCE").Aggregate, "user1", []string{"IAM_OWNER"}..., - ), + ).WithUserResourceOwner("org1"), ), ), expectPush( @@ -692,7 +692,7 @@ func TestCommandSide_ChangeInstanceMember(t *testing.T) { &instance.NewAggregate("INSTANCE").Aggregate, "user1", []string{"IAM_OWNER"}..., - ), + ).WithUserResourceOwner("org1"), ), ), ), @@ -800,7 +800,7 @@ func TestCommandSide_RemoveInstanceMember(t *testing.T) { &instance.NewAggregate("INSTANCE").Aggregate, "user1", []string{"IAM_OWNER"}..., - ), + ).WithUserResourceOwner("org1"), ), ), expectPush( @@ -832,7 +832,7 @@ func TestCommandSide_RemoveInstanceMember(t *testing.T) { &instance.NewAggregate("INSTANCE").Aggregate, "user1", []string{"IAM_OWNER"}..., - ), + ).WithUserResourceOwner("org1"), ), ), ), diff --git a/internal/command/instance_test.go b/internal/command/instance_test.go index da48474cf..0fb25a3ab 100644 --- a/internal/command/instance_test.go +++ b/internal/command/instance_test.go @@ -663,8 +663,8 @@ func humanEvents(ctx context.Context, instanceID, orgID, userID string) []events return []eventstore.Command{ addHumanEvent(ctx, orgID, userID), user.NewHumanEmailVerifiedEvent(ctx, &agg.Aggregate), - org.NewMemberAddedEvent(ctx, &orgAgg.Aggregate, userID, domain.RoleOrgOwner), - instance.NewMemberAddedEvent(ctx, &instanceAgg.Aggregate, userID, domain.RoleIAMOwner), + org.NewMemberAddedEvent(ctx, &orgAgg.Aggregate, userID, domain.RoleOrgOwner).WithUserResourceOwner(orgID), + instance.NewMemberAddedEvent(ctx, &instanceAgg.Aggregate, userID, domain.RoleIAMOwner).WithUserResourceOwner(orgID), } } @@ -707,8 +707,8 @@ func machineEvents(ctx context.Context, instanceID, orgID, userID, patID string) ) } return append(events, - org.NewMemberAddedEvent(ctx, &orgAgg.Aggregate, userID, domain.RoleOrgOwner), - instance.NewMemberAddedEvent(ctx, &instanceAgg.Aggregate, userID, domain.RoleIAMOwner), + org.NewMemberAddedEvent(ctx, &orgAgg.Aggregate, userID, domain.RoleOrgOwner).WithUserResourceOwner(orgID), + instance.NewMemberAddedEvent(ctx, &instanceAgg.Aggregate, userID, domain.RoleIAMOwner).WithUserResourceOwner(orgID), ) } @@ -730,7 +730,7 @@ func loginClientEvents(ctx context.Context, instanceID, orgID, userID, patID str instanceAgg := instance.NewAggregate(instanceID) events := []eventstore.Command{ addLoginClientEvent(ctx, orgID, userID), - instance.NewMemberAddedEvent(ctx, &instanceAgg.Aggregate, userID, domain.RoleIAMLoginClient), + instance.NewMemberAddedEvent(ctx, &instanceAgg.Aggregate, userID, domain.RoleIAMLoginClient).WithUserResourceOwner(orgID), } if patID != "" { events = append(events, diff --git a/internal/command/org.go b/internal/command/org.go index a525292e4..41e062061 100644 --- a/internal/command/org.go +++ b/internal/command/org.go @@ -395,7 +395,7 @@ func (c *Commands) addOrgWithIDAndMember(ctx context.Context, name, userID, reso if err != nil { return nil, err } - _, err = c.checkUserExists(ctx, userID, resourceOwner) + userResourceOwner, err := c.checkUserExists(ctx, userID, resourceOwner) if err != nil { return nil, err } @@ -403,7 +403,7 @@ func (c *Commands) addOrgWithIDAndMember(ctx context.Context, name, userID, reso if err := addMember.IsValid(c.zitadelRoles); err != nil { return nil, err } - events = append(events, org.NewMemberAddedEvent(ctx, orgAgg, addMember.UserID, addMember.Roles...)) + events = append(events, org.NewMemberAddedEvent(ctx, orgAgg, addMember.UserID, addMember.Roles...).WithUserResourceOwner(userResourceOwner)) if setOrgInactive { deactivateOrgEvent := org.NewOrgDeactivatedEvent(ctx, orgAgg) events = append(events, deactivateOrgEvent) @@ -578,6 +578,14 @@ func (c *Commands) prepareRemoveOrg(a *org.Aggregate, permissionCheck Organizati return nil, nil } + ownerDeleteReady, err := c.isUniqueConstraintOwnerDeleteReady(ctx) + if err != nil { + return nil, err + } + if ownerDeleteReady { + return []eventstore.Command{org.NewOrgRemovedEvent(ctx, &a.Aggregate, writeModel.Name, nil, false, nil, nil, nil)}, nil + } + domainPolicy, err := domainPolicyWriteModel(ctx, filter, a.ID) if err != nil { return nil, err @@ -588,10 +596,14 @@ func (c *Commands) prepareRemoveOrg(a *org.Aggregate, permissionCheck Organizati return nil, err } - usernames, err := OrgUsers(ctx, filter, a.ID) + orgUsers, err := OrgUsers(ctx, filter, a.ID) if err != nil { return nil, err } + usernames := make([]string, len(orgUsers)) + for i, u := range orgUsers { + usernames[i] = u.Username + } domains, err := OrgDomains(ctx, filter, a.ID) if err != nil { return nil, err @@ -733,12 +745,7 @@ func OrgDomains(ctx context.Context, filter preparation.FilterToQueryReducer, or return names, nil } -type userIDName struct { - name string - id string -} - -func OrgUsers(ctx context.Context, filter preparation.FilterToQueryReducer, orgID string) ([]string, error) { +func OrgUsers(ctx context.Context, filter preparation.FilterToQueryReducer, orgID string) ([]user.UsernameChange, error) { events, err := filter(ctx, eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). InstanceID(authz.GetInstance(ctx).InstanceID()). ResourceOwner(orgID). @@ -757,30 +764,30 @@ func OrgUsers(ctx context.Context, filter preparation.FilterToQueryReducer, orgI return nil, err } - users := make([]userIDName, 0) + users := make([]user.UsernameChange, 0) for _, event := range events { switch eventTyped := event.(type) { case *user.HumanAddedEvent: - users = append(users, userIDName{eventTyped.UserName, eventTyped.Aggregate().ID}) + users = append(users, user.UsernameChange{Username: eventTyped.UserName, UserID: eventTyped.Aggregate().ID}) case *user.MachineAddedEvent: - users = append(users, userIDName{eventTyped.UserName, eventTyped.Aggregate().ID}) + users = append(users, user.UsernameChange{Username: eventTyped.UserName, UserID: eventTyped.Aggregate().ID}) case *user.HumanRegisteredEvent: - users = append(users, userIDName{eventTyped.UserName, eventTyped.Aggregate().ID}) + users = append(users, user.UsernameChange{Username: eventTyped.UserName, UserID: eventTyped.Aggregate().ID}) case *user.DomainClaimedEvent: for i := range users { - if users[i].id == eventTyped.Aggregate().ID { - users[i].name = eventTyped.UserName + if users[i].UserID == eventTyped.Aggregate().ID { + users[i].Username = eventTyped.UserName } } case *user.UsernameChangedEvent: for i := range users { - if users[i].id == eventTyped.Aggregate().ID { - users[i].name = eventTyped.UserName + if users[i].UserID == eventTyped.Aggregate().ID { + users[i].Username = eventTyped.UserName } } case *user.UserRemovedEvent: for i := range users { - if users[i].id == eventTyped.Aggregate().ID { + if users[i].UserID == eventTyped.Aggregate().ID { users[i] = users[len(users)-1] users = users[:len(users)-1] break @@ -788,11 +795,7 @@ func OrgUsers(ctx context.Context, filter preparation.FilterToQueryReducer, orgI } } } - names := make([]string, len(users)) - for i := range users { - names[i] = users[i].name - } - return names, nil + return users, nil } func ExistsOrg(ctx context.Context, filter preparation.FilterToQueryReducer, id string) (exists bool, err error) { diff --git a/internal/command/org_member.go b/internal/command/org_member.go index ae7af401c..920e2bbe6 100644 --- a/internal/command/org_member.go +++ b/internal/command/org_member.go @@ -22,13 +22,14 @@ func (c *Commands) AddOrgMemberCommand(member *AddOrgMember) preparation.Validat ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - if exists, err := ExistsUser(ctx, filter, member.UserID, "", false); err != nil || !exists { + userResourceOwner, exists, err := existingUser(ctx, filter, member.UserID, "", false) + if err != nil || !exists { return nil, zerrors.ThrowPreconditionFailed(err, "ORG-GoXOn", "Errors.User.NotFound") } if isMember, err := IsOrgMember(ctx, filter, member.OrgID, member.UserID); err != nil || isMember { return nil, zerrors.ThrowAlreadyExists(err, "ORG-poWwe", "Errors.Org.Member.AlreadyExists") } - return []eventstore.Command{org.NewMemberAddedEvent(ctx, &org.NewAggregate(member.OrgID).Aggregate, member.UserID, member.Roles...)}, nil + return []eventstore.Command{org.NewMemberAddedEvent(ctx, &org.NewAggregate(member.OrgID).Aggregate, member.UserID, member.Roles...).WithUserResourceOwner(userResourceOwner)}, nil }, nil } diff --git a/internal/command/org_member_test.go b/internal/command/org_member_test.go index 25dd3b818..127430176 100644 --- a/internal/command/org_member_test.go +++ b/internal/command/org_member_test.go @@ -165,7 +165,7 @@ func TestAddMember(t *testing.T) { }, want: Want{ Commands: []eventstore.Command{ - org.NewMemberAddedEvent(ctx, &org.NewAggregate("test").Aggregate, "userID", "ORG_OWNER"), + org.NewMemberAddedEvent(ctx, &org.NewAggregate("test").Aggregate, "userID", "ORG_OWNER").WithUserResourceOwner("ro"), }, }, }, @@ -482,7 +482,7 @@ func TestCommandSide_AddOrgMember(t *testing.T) { &org.NewAggregate("org1").Aggregate, "user1", []string{"ORG_OWNER"}..., - ), + ).WithUserResourceOwner("org1"), ), ), checkPermission: newMockPermissionCheckAllowed(), @@ -537,7 +537,7 @@ func TestCommandSide_AddOrgMember(t *testing.T) { &org.NewAggregate("org1").Aggregate, "user1", []string{"ORG_OWNER"}..., - ), + ).WithUserResourceOwner("org1"), ), ), checkPermission: newMockPermissionCheckAllowed(), diff --git a/internal/command/org_test.go b/internal/command/org_test.go index 0d8c51f05..310054dfa 100644 --- a/internal/command/org_test.go +++ b/internal/command/org_test.go @@ -213,7 +213,7 @@ func TestCommandSide_AddOrg(t *testing.T) { context.Background(), &org.NewAggregate("org2").Aggregate, "user1", domain.RoleOrgOwner, - ), + ).WithUserResourceOwner("org1"), ), ), idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "org2"), @@ -279,7 +279,7 @@ func TestCommandSide_AddOrg(t *testing.T) { context.Background(), &org.NewAggregate("org2").Aggregate, "user1", domain.RoleOrgOwner, - ), + ).WithUserResourceOwner("org1"), ), ), idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "org2"), @@ -340,7 +340,7 @@ func TestCommandSide_AddOrg(t *testing.T) { &org.NewAggregate("org2").Aggregate, "user1", domain.RoleOrgOwner, - ), + ).WithUserResourceOwner("org1"), ), ), idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "org2"), @@ -409,7 +409,7 @@ func TestCommandSide_AddOrg(t *testing.T) { &org.NewAggregate("org2").Aggregate, "user1", domain.RoleOrgOwner, - ), + ).WithUserResourceOwner("org1"), ), ), idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "org2"), @@ -1493,6 +1493,29 @@ func TestCommandSide_RemoveOrg(t *testing.T) { } } +func TestCommandSide_RemoveOrgOwnerDeleteReady(t *testing.T) { + r := &Commands{ + eventstore: expectEventstore( + expectFilter(), // zitadel project check + expectFilter( + eventFromEventPusher( + org.NewOrgAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "org"), + ), + ), + expectPush( + org.NewOrgRemovedEvent( + context.Background(), &org.NewAggregate("org1").Aggregate, "org", nil, false, nil, nil, nil, + ), + ), + )(t), + ownerDeleteReady: func(context.Context) (bool, error) { return true, nil }, + } + _, err := r.RemoveOrg(context.Background(), "org1", nil, false) + assert.NoError(t, err) +} + func TestCommandSide_SetUpOrg(t *testing.T) { type fields struct { eventstore func(t *testing.T) *eventstore.Eventstore diff --git a/internal/command/organization_settings.go b/internal/command/organization_settings.go index 2459fd60d..fa06bafb1 100644 --- a/internal/command/organization_settings.go +++ b/internal/command/organization_settings.go @@ -5,6 +5,7 @@ import ( "github.com/zitadel/zitadel/internal/command/preparation" "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/repository/user" "github.com/zitadel/zitadel/internal/telemetry/tracing" "github.com/zitadel/zitadel/internal/zerrors" ) @@ -127,14 +128,14 @@ func (c *Commands) getOrganizationScopedUsernamesWriteModelByID(ctx context.Cont return wm, nil } -func (c *Commands) getOrganizationScopedUsernames(ctx context.Context, id string) ([]string, error) { +func (c *Commands) getOrganizationScopedUsernames(ctx context.Context, id string) ([]user.UsernameChange, error) { wm, err := c.getOrganizationScopedUsernamesWriteModelByID(ctx, id) if err != nil { return nil, err } - usernames := make([]string, len(wm.Users)) - for i, user := range wm.Users { - usernames[i] = user.username + usernames := make([]user.UsernameChange, len(wm.Users)) + for i, u := range wm.Users { + usernames[i] = user.UsernameChange{Username: u.username, UserID: u.id} } return usernames, nil } diff --git a/internal/command/organization_settings_model.go b/internal/command/organization_settings_model.go index 890cf671e..f99f3413d 100644 --- a/internal/command/organization_settings_model.go +++ b/internal/command/organization_settings_model.go @@ -98,7 +98,7 @@ func (wm *OrganizationSettingsWriteModel) NewSet( ctx context.Context, organizationScopedUsernames *bool, userLoginMustBeDomain bool, - usernamesF func(ctx context.Context, orgID string) ([]string, error), + usernamesF func(ctx context.Context, orgID string) ([]user.UsernameChange, error), ) (_ []eventstore.Command, err error) { if err := wm.checkPermissionWrite(ctx, wm.ResourceOwner, wm.AggregateID); err != nil { return nil, err @@ -108,7 +108,7 @@ func (wm *OrganizationSettingsWriteModel) NewSet( return nil, nil } - var usernames []string + var usernames []user.UsernameChange if (wm.OrganizationScopedUsernames || userLoginMustBeDomain) != (*organizationScopedUsernames || userLoginMustBeDomain) { usernames, err = usernamesF(ctx, wm.AggregateID) if err != nil { @@ -129,13 +129,13 @@ func (wm *OrganizationSettingsWriteModel) NewSet( func (wm *OrganizationSettingsWriteModel) NewRemoved( ctx context.Context, userLoginMustBeDomain bool, - usernamesF func(ctx context.Context, orgID string) ([]string, error), + usernamesF func(ctx context.Context, orgID string) ([]user.UsernameChange, error), ) (_ []eventstore.Command, err error) { if err := wm.checkPermissionDelete(ctx, wm.ResourceOwner, wm.AggregateID); err != nil { return nil, err } - var usernames []string + var usernames []user.UsernameChange if userLoginMustBeDomain != wm.OrganizationScopedUsernames { usernames, err = usernamesF(ctx, wm.AggregateID) if err != nil { diff --git a/internal/command/organization_settings_test.go b/internal/command/organization_settings_test.go index f6317fd35..89fab3ff6 100644 --- a/internal/command/organization_settings_test.go +++ b/internal/command/organization_settings_test.go @@ -103,7 +103,7 @@ func TestCommandSide_SetSettingsOrganization(t *testing.T) { expectPush( settings.NewOrganizationSettingsAddedEvent(context.Background(), &settings.NewAggregate("org1", "org1").Aggregate, - []string{"username1", "username2", "username3"}, + []user.UsernameChange{{Username: "username1", UserID: "username1"}, {Username: "username2", UserID: "username2"}, {Username: "username3", UserID: "username3"}}, true, false, ), @@ -155,7 +155,7 @@ func TestCommandSide_SetSettingsOrganization(t *testing.T) { expectPush( settings.NewOrganizationSettingsAddedEvent(context.Background(), &settings.NewAggregate("org1", "org1").Aggregate, - []string{"username1", "username2", "username3"}, + []user.UsernameChange{{Username: "username1", UserID: "username1"}, {Username: "username2", UserID: "username2"}, {Username: "username3", UserID: "username3"}}, true, false, ), @@ -209,7 +209,7 @@ func TestCommandSide_SetSettingsOrganization(t *testing.T) { expectPush( settings.NewOrganizationSettingsAddedEvent(context.Background(), &settings.NewAggregate("org1", "org1").Aggregate, - []string{"username1", "username2", "username3"}, + []user.UsernameChange{{Username: "username1", UserID: "username1"}, {Username: "username2", UserID: "username2"}, {Username: "username3", UserID: "username3"}}, true, true, ), @@ -314,7 +314,7 @@ func TestCommandSide_DeleteSettingsOrganization(t *testing.T) { expectPush( settings.NewOrganizationSettingsRemovedEvent(context.Background(), &settings.NewAggregate("org1", "org1").Aggregate, - []string{"username1", "username2", "username3"}, + []user.UsernameChange{{Username: "username1", UserID: "username1"}, {Username: "username2", UserID: "username2"}, {Username: "username3", UserID: "username3"}}, false, true, ), @@ -342,7 +342,7 @@ func TestCommandSide_DeleteSettingsOrganization(t *testing.T) { expectPush( settings.NewOrganizationSettingsRemovedEvent(context.Background(), &settings.NewAggregate("org1", "org1").Aggregate, - []string{"username1", "username2", "username3"}, + []user.UsernameChange{{Username: "username1", UserID: "username1"}, {Username: "username2", UserID: "username2"}, {Username: "username3", UserID: "username3"}}, false, false, ), @@ -371,7 +371,7 @@ func TestCommandSide_DeleteSettingsOrganization(t *testing.T) { expectPush( settings.NewOrganizationSettingsRemovedEvent(context.Background(), &settings.NewAggregate("org1", "org1").Aggregate, - []string{"username1", "username2", "username3"}, + []user.UsernameChange{{Username: "username1", UserID: "username1"}, {Username: "username2", UserID: "username2"}, {Username: "username3", UserID: "username3"}}, true, true, ), @@ -399,7 +399,7 @@ func TestCommandSide_DeleteSettingsOrganization(t *testing.T) { expectPush( settings.NewOrganizationSettingsRemovedEvent(context.Background(), &settings.NewAggregate("org1", "org1").Aggregate, - []string{"username1", "username2", "username3"}, + []user.UsernameChange{{Username: "username1", UserID: "username1"}, {Username: "username2", UserID: "username2"}, {Username: "username3", UserID: "username3"}}, true, true, ), @@ -497,7 +497,7 @@ func expectFilterOrganizationSettingsEvents(ctx context.Context, orgID string, s eventFromEventPusher( settings.NewOrganizationSettingsAddedEvent(ctx, &settings.NewAggregate(orgID, orgID).Aggregate, - []string{}, + nil, orgScopedUsernames, !orgScopedUsernames, ), diff --git a/internal/command/project.go b/internal/command/project.go index 4d060b623..5d5f5f450 100644 --- a/internal/command/project.go +++ b/internal/command/project.go @@ -327,16 +327,11 @@ func (c *Commands) RemoveProject(ctx context.Context, projectID, resourceOwner s return nil, zerrors.ThrowNotFound(nil, "COMMAND-3M9sd", "Errors.Project.NotFound") } - samlEntityIDsAgg, err := c.getSAMLEntityIdsWriteModelByProjectID(ctx, projectID, resourceOwner) + uniqueConstraints, err := c.projectRemovedUniqueConstraints(ctx, projectID, resourceOwner) if err != nil { return nil, err } - uniqueConstraints := make([]*eventstore.UniqueConstraint, len(samlEntityIDsAgg.EntityIDs)) - for i, entityID := range samlEntityIDsAgg.EntityIDs { - uniqueConstraints[i] = project.NewRemoveSAMLConfigEntityIDUniqueConstraint(entityID.EntityID) - } - events := []eventstore.Command{ project.NewProjectRemovedEvent(ctx, ProjectAggregateFromWriteModelWithCTX(ctx, &existingProject.WriteModel), @@ -381,15 +376,10 @@ func (c *Commands) DeleteProject(ctx context.Context, id, resourceOwner string, return time.Time{}, err } - samlEntityIDsAgg, err := c.getSAMLEntityIdsWriteModelByProjectID(ctx, id, resourceOwner) + uniqueConstraints, err := c.projectRemovedUniqueConstraints(ctx, id, resourceOwner) if err != nil { return time.Time{}, err } - - uniqueConstraints := make([]*eventstore.UniqueConstraint, len(samlEntityIDsAgg.EntityIDs)) - for i, entityID := range samlEntityIDsAgg.EntityIDs { - uniqueConstraints[i] = project.NewRemoveSAMLConfigEntityIDUniqueConstraint(entityID.EntityID) - } events := []eventstore.Command{ project.NewProjectRemovedEvent(ctx, ProjectAggregateFromWriteModelWithCTX(ctx, &existing.WriteModel), @@ -412,6 +402,25 @@ func (c *Commands) DeleteProject(ctx context.Context, id, resourceOwner string, return existing.WriteModel.ChangeDate, nil } +func (c *Commands) projectRemovedUniqueConstraints(ctx context.Context, projectID, resourceOwner string) ([]*eventstore.UniqueConstraint, error) { + ownerDeleteReady, err := c.isUniqueConstraintOwnerDeleteReady(ctx) + if err != nil { + return nil, err + } + if ownerDeleteReady { + return nil, nil + } + samlEntityIDsAgg, err := c.getSAMLEntityIdsWriteModelByProjectID(ctx, projectID, resourceOwner) + if err != nil { + return nil, err + } + uniqueConstraints := make([]*eventstore.UniqueConstraint, len(samlEntityIDsAgg.EntityIDs)) + for i, entityID := range samlEntityIDsAgg.EntityIDs { + uniqueConstraints[i] = project.NewRemoveSAMLConfigEntityIDUniqueConstraint(entityID.EntityID) + } + return uniqueConstraints, nil +} + func (c *Commands) getProjectWriteModelByID(ctx context.Context, projectID, resourceOwner string) (_ *ProjectWriteModel, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() diff --git a/internal/command/project_grant_member.go b/internal/command/project_grant_member.go index f04f3dfaa..cf246e8d1 100644 --- a/internal/command/project_grant_member.go +++ b/internal/command/project_grant_member.go @@ -38,7 +38,7 @@ func (c *Commands) AddProjectGrantMember(ctx context.Context, member *AddProject if err := member.IsValid(c.zitadelRoles); err != nil { return nil, err } - _, err = c.checkUserExists(ctx, member.UserID, "") + userResourceOwner, err := c.checkUserExists(ctx, member.UserID, "") if err != nil { return nil, err } @@ -76,7 +76,7 @@ func (c *Commands) AddProjectGrantMember(ctx context.Context, member *AddProject member.UserID, member.ProjectGrantID, member.Roles..., - )) + ).WithOwnerOrgs(userResourceOwner, grantedOrgID)) if err != nil { return nil, err } diff --git a/internal/command/project_grant_member_test.go b/internal/command/project_grant_member_test.go index f6a48a5dc..85ae6bf0d 100644 --- a/internal/command/project_grant_member_test.go +++ b/internal/command/project_grant_member_test.go @@ -130,7 +130,7 @@ func TestCommandSide_AddProjectGrantMember(t *testing.T) { "user1", "projectgrant1", []string{"PROJECT_GRANT_OWNER"}..., - ), + ).WithOwnerOrgs("org1", "grantedorg1"), ), ), checkPermission: newMockPermissionCheckAllowed(), @@ -190,7 +190,7 @@ func TestCommandSide_AddProjectGrantMember(t *testing.T) { "user1", "projectgrant1", []string{"PROJECT_GRANT_OWNER"}..., - ), + ).WithOwnerOrgs("org1", "grantedorg1"), ), ), checkPermission: newMockPermissionCheckAllowed(), diff --git a/internal/command/project_member.go b/internal/command/project_member.go index 1bbd35849..1b2cb751b 100644 --- a/internal/command/project_member.go +++ b/internal/command/project_member.go @@ -36,7 +36,7 @@ func (c *Commands) AddProjectMember(ctx context.Context, member *AddProjectMembe if err := member.IsValid(c.zitadelRoles); err != nil { return nil, err } - _, err = c.checkUserExists(ctx, member.UserID, "") + userResourceOwner, err := c.checkUserExists(ctx, member.UserID, "") if err != nil { return nil, err } @@ -68,7 +68,7 @@ func (c *Commands) AddProjectMember(ctx context.Context, member *AddProjectMembe ProjectAggregateFromWriteModelWithCTX(ctx, &addedMember.WriteModel), member.UserID, member.Roles..., - ), + ).WithUserResourceOwner(userResourceOwner), ) if err != nil { return nil, err diff --git a/internal/command/project_member_test.go b/internal/command/project_member_test.go index 1bd06e0b9..7981f9b52 100644 --- a/internal/command/project_member_test.go +++ b/internal/command/project_member_test.go @@ -193,7 +193,7 @@ func TestCommandSide_AddProjectMember(t *testing.T) { &project.NewAggregate("project1", "org1").Aggregate, "user1", []string{"PROJECT_OWNER"}..., - ), + ).WithUserResourceOwner("org1"), ), ), checkPermission: newMockPermissionCheckAllowed(), @@ -254,7 +254,7 @@ func TestCommandSide_AddProjectMember(t *testing.T) { &project.NewAggregate("project1", "org1").Aggregate, "user1", []string{"PROJECT_OWNER"}..., - ), + ).WithUserResourceOwner("org1"), ), ), checkPermission: newMockPermissionCheckAllowed(), diff --git a/internal/command/project_test.go b/internal/command/project_test.go index 6c03420f6..e78e1dca4 100644 --- a/internal/command/project_test.go +++ b/internal/command/project_test.go @@ -6,6 +6,7 @@ import ( "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/domain" @@ -1246,6 +1247,32 @@ func TestCommandSide_RemoveProject(t *testing.T) { } } +func TestCommandSide_RemoveProjectOwnerDeleteReady(t *testing.T) { + r := &Commands{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + project.NewProjectAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "project", true, true, true, + domain.PrivateLabelingSettingAllowLoginUserResourceOwnerPolicy), + ), + ), + expectPush( + project.NewProjectRemovedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "project", + nil), + ), + )(t), + checkPermission: newMockPermissionCheckAllowed(), + ownerDeleteReady: func(context.Context) (bool, error) { return true, nil }, + } + got, err := r.RemoveProject(context.Background(), "project1", "org1") + require.NoError(t, err) + assertObjectDetails(t, &domain.ObjectDetails{ResourceOwner: "org1"}, got) +} + func TestCommandSide_DeleteProject(t *testing.T) { type fields struct { eventstore func(t *testing.T) *eventstore.Eventstore diff --git a/internal/command/unique_constraint_owners.go b/internal/command/unique_constraint_owners.go new file mode 100644 index 000000000..0fe62ab6f --- /dev/null +++ b/internal/command/unique_constraint_owners.go @@ -0,0 +1,70 @@ +package command + +import ( + "context" + + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/migration" +) + +var _ eventstore.QueryReducer = (*uniqueConstraintOwnersBackfillState)(nil) + +func (c *Commands) isUniqueConstraintOwnerDeleteReady(ctx context.Context) (bool, error) { + if c.ownerDeleteReadyCached.Load() { + return true, nil + } + if c.ownerDeleteReady == nil { + return false, nil + } + ready, err := c.ownerDeleteReady(ctx) + if err != nil { + return false, err + } + if ready { + c.ownerDeleteReadyCached.Store(true) + } + return ready, nil +} + +func (c *Commands) uniqueConstraintOwnersBackfillFinalized(ctx context.Context) (bool, error) { + var state uniqueConstraintOwnersBackfillState + if err := c.eventstore.FilterToQueryReducer(ctx, &state); err != nil { + return false, err + } + return state.finalized, nil +} + +type uniqueConstraintOwnersBackfillState struct { + eventstore.ReadModel + finalized bool +} + +func (*uniqueConstraintOwnersBackfillState) Query() *eventstore.SearchQueryBuilder { + return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + InstanceID(""). + AddQuery(). + AggregateTypes(migration.SystemAggregate). + AggregateIDs(migration.SystemAggregateID). + EventTypes(eventstore.EventType("system.migration.repeatable.done")). + Builder() +} + +func (s *uniqueConstraintOwnersBackfillState) Reduce() error { + for _, event := range s.Events { + if event.Type() != eventstore.EventType("system.migration.repeatable.done") { + continue + } + step, ok := event.(*migration.SetupStep) + if !ok || step.Name != eventstore.UniqueConstraintOwnersBackfillStep { + continue + } + lastRun, _ := step.LastRun.(map[string]interface{}) + if lastRun == nil { + s.finalized = false + continue + } + finalized, _ := lastRun["finalized"].(bool) + s.finalized = finalized + } + return s.ReadModel.Reduce() +} diff --git a/internal/command/unique_constraint_owners_test.go b/internal/command/unique_constraint_owners_test.go new file mode 100644 index 000000000..6d8d7cfd0 --- /dev/null +++ b/internal/command/unique_constraint_owners_test.go @@ -0,0 +1,165 @@ +package command + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/eventstore/repository" + "github.com/zitadel/zitadel/internal/migration" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func TestIsUniqueConstraintOwnerDeleteReady(t *testing.T) { + t.Run("nil checker is not ready", func(t *testing.T) { + c := &Commands{} + ready, err := c.isUniqueConstraintOwnerDeleteReady(t.Context()) + require.NoError(t, err) + assert.False(t, ready) + }) + + t.Run("ready is cached", func(t *testing.T) { + calls := 0 + c := &Commands{ + ownerDeleteReady: func(context.Context) (bool, error) { + calls++ + return true, nil + }, + } + ready, err := c.isUniqueConstraintOwnerDeleteReady(t.Context()) + require.NoError(t, err) + assert.True(t, ready) + ready, err = c.isUniqueConstraintOwnerDeleteReady(t.Context()) + require.NoError(t, err) + assert.True(t, ready) + assert.Equal(t, 1, calls) + }) + + t.Run("not ready is not cached", func(t *testing.T) { + calls := 0 + c := &Commands{ + ownerDeleteReady: func(context.Context) (bool, error) { + calls++ + return false, nil + }, + } + _, err := c.isUniqueConstraintOwnerDeleteReady(t.Context()) + require.NoError(t, err) + _, err = c.isUniqueConstraintOwnerDeleteReady(t.Context()) + require.NoError(t, err) + assert.Equal(t, 2, calls) + }) +} + +func TestUniqueConstraintOwnersBackfillFinalized(t *testing.T) { + t.Run("no backfill event", func(t *testing.T) { + c := &Commands{eventstore: expectEventstore(expectFilter())(t)} + ready, err := c.uniqueConstraintOwnersBackfillFinalized(t.Context()) + require.NoError(t, err) + assert.False(t, ready) + }) + + t.Run("finalized false is not ready", func(t *testing.T) { + c := &Commands{eventstore: expectEventstore( + expectFilter(uniqueConstraintOwnersBackfillDoneEvent("v2.0.0", false)), + )(t)} + ready, err := c.uniqueConstraintOwnersBackfillFinalized(t.Context()) + require.NoError(t, err) + assert.False(t, ready) + }) + + t.Run("finalized true is ready", func(t *testing.T) { + c := &Commands{eventstore: expectEventstore( + expectFilter(uniqueConstraintOwnersBackfillDoneEvent("other-version", true)), + )(t)} + ready, err := c.uniqueConstraintOwnersBackfillFinalized(t.Context()) + require.NoError(t, err) + assert.True(t, ready) + }) + + t.Run("missing finalized key is not ready", func(t *testing.T) { + c := &Commands{eventstore: expectEventstore( + expectFilter(uniqueConstraintOwnersBackfillDoneEventLastRun(map[string]any{"version": "v2.0.0"})), + )(t)} + ready, err := c.uniqueConstraintOwnersBackfillFinalized(t.Context()) + require.NoError(t, err) + assert.False(t, ready) + }) + + t.Run("failed event with finalized true is not ready", func(t *testing.T) { + c := &Commands{eventstore: expectEventstore( + expectFilter(uniqueConstraintOwnersBackfillFailedEvent("v2.0.0", true)), + )(t)} + ready, err := c.uniqueConstraintOwnersBackfillFinalized(t.Context()) + require.NoError(t, err) + assert.False(t, ready) + }) + + t.Run("failed then done finalized is ready", func(t *testing.T) { + c := &Commands{eventstore: expectEventstore( + expectFilter( + uniqueConstraintOwnersBackfillFailedEvent("v2.0.0", true), + uniqueConstraintOwnersBackfillDoneEvent("v2.0.0", true), + ), + )(t)} + ready, err := c.uniqueConstraintOwnersBackfillFinalized(t.Context()) + require.NoError(t, err) + assert.True(t, ready) + }) + + t.Run("filter error", func(t *testing.T) { + c := &Commands{eventstore: expectEventstore( + expectFilterError(zerrors.ThrowInternal(nil, "id", "err")), + )(t)} + _, err := c.uniqueConstraintOwnersBackfillFinalized(t.Context()) + assert.Error(t, err) + }) +} + +func uniqueConstraintOwnersBackfillDoneEvent(version string, finalized bool) *repository.Event { + return uniqueConstraintOwnersBackfillEvent(eventstore.EventType("system.migration.repeatable.done"), map[string]any{ + "version": version, + "finalized": finalized, + }) +} + +func uniqueConstraintOwnersBackfillFailedEvent(version string, finalized bool) *repository.Event { + return uniqueConstraintOwnersBackfillEvent(eventstore.EventType("system.migration.failed"), map[string]any{ + "version": version, + "finalized": finalized, + }) +} + +func uniqueConstraintOwnersBackfillDoneEventLastRun(lastRun map[string]any) *repository.Event { + return uniqueConstraintOwnersBackfillEvent(eventstore.EventType("system.migration.repeatable.done"), lastRun) +} + +func uniqueConstraintOwnersBackfillEvent(typ eventstore.EventType, lastRun map[string]any) *repository.Event { + ctx := authz.WithInstanceID(context.Background(), "") + cmd := &migration.SetupStep{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + eventstore.NewAggregate(ctx, migration.SystemAggregateID, migration.SystemAggregate, "v1"), + typ, + ), + Name: eventstore.UniqueConstraintOwnersBackfillStep, + LastRun: lastRun, + } + data, err := eventstore.EventData(cmd) + if err != nil { + panic(err) + } + return &repository.Event{ + InstanceID: cmd.Aggregate().InstanceID, + Typ: cmd.Type(), + Data: data, + EditorUser: cmd.Creator(), + Version: cmd.Aggregate().Version, + AggregateID: cmd.Aggregate().ID, + AggregateType: cmd.Aggregate().Type, + } +} diff --git a/internal/command/user.go b/internal/command/user.go index 93ea98414..e6f3b60ef 100644 --- a/internal/command/user.go +++ b/internal/command/user.go @@ -200,7 +200,19 @@ func (c *Commands) RemoveUser(ctx context.Context, userID, resourceOwner string, } var events []eventstore.Command userAgg := UserAggregateFromWriteModel(&existingUser.WriteModel) - events = append(events, user.NewUserRemovedEvent(ctx, userAgg, existingUser.UserName, existingUser.IDPLinks, domainPolicy.UserLoginMustBeDomain || orgScopedUsername)) + userName := existingUser.UserName + idpLinks := existingUser.IDPLinks + orgScoped := domainPolicy.UserLoginMustBeDomain || orgScopedUsername + ownerDeleteReady, err := c.isUniqueConstraintOwnerDeleteReady(ctx) + if err != nil { + return nil, err + } + if ownerDeleteReady { + userName = "" + idpLinks = nil + orgScoped = false + } + events = append(events, user.NewUserRemovedEvent(ctx, userAgg, userName, idpLinks, orgScoped)) for _, grantID := range cascadingGrantIDs { removeEvent, _, err := c.removeUserGrant(ctx, grantID, "", true, false, nil) @@ -387,6 +399,11 @@ func (c *Commands) userWriteModelByID(ctx context.Context, userID, resourceOwner } func ExistsUser(ctx context.Context, filter preparation.FilterToQueryReducer, id, resourceOwner string, machineOnly bool) (exists bool, err error) { + _, exists, err = existingUser(ctx, filter, id, resourceOwner, machineOnly) + return exists, err +} + +func existingUser(ctx context.Context, filter preparation.FilterToQueryReducer, id, resourceOwner string, machineOnly bool) (userResourceOwner string, exists bool, err error) { eventTypes := []eventstore.EventType{ user.MachineAddedEventType, user.UserRemovedType, @@ -408,19 +425,21 @@ func ExistsUser(ctx context.Context, filter preparation.FilterToQueryReducer, id EventTypes(eventTypes...). Builder()) if err != nil { - return false, err + return "", false, err } for _, event := range events { switch event.(type) { case *user.HumanRegisteredEvent, *user.HumanAddedEvent, *user.MachineAddedEvent: exists = true + userResourceOwner = event.Aggregate().ResourceOwner case *user.UserRemovedEvent: exists = false + userResourceOwner = "" } } - return exists, nil + return userResourceOwner, exists, nil } func (c *Commands) newUserInitCode(ctx context.Context, filter preparation.FilterToQueryReducer, alg crypto.EncryptionAlgorithm) (*EncryptedCode, error) { diff --git a/internal/command/user_grant.go b/internal/command/user_grant.go index a4703a237..47b7042c4 100644 --- a/internal/command/user_grant.go +++ b/internal/command/user_grant.go @@ -42,7 +42,7 @@ func (c *Commands) addUserGrant(ctx context.Context, userGrant *domain.UserGrant if !userGrant.IsValid() { return nil, nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-kVfMa", "Errors.UserGrant.Invalid") } - err = c.checkUserGrantPreCondition(ctx, userGrant, check) + owners, err := c.checkUserGrantPreCondition(ctx, userGrant, check) if err != nil { return nil, nil, err } @@ -60,7 +60,7 @@ func (c *Commands) addUserGrant(ctx context.Context, userGrant *domain.UserGrant userGrant.ProjectID, userGrant.ProjectGrantID, userGrant.RoleKeys, - ) + ).WithOwnerOrgs(owners.userResourceOwner, owners.projectResourceOwner, owners.grantedOrg) return command, addedUserGrant, nil } @@ -88,7 +88,7 @@ func (c *Commands) ChangeUserGrant(ctx context.Context, userGrant *domain.UserGr userGrant.ProjectGrantID = existingUserGrant.ProjectGrantID userGrant.ResourceOwner = existingUserGrant.ResourceOwner - err = c.checkUserGrantPreCondition(ctx, userGrant, check) + _, err = c.checkUserGrantPreCondition(ctx, userGrant, check) if err != nil { return nil, err } @@ -119,7 +119,7 @@ func (c *Commands) removeRoleFromUserGrant(ctx context.Context, userGrantID stri if existingUserGrant.State == domain.UserGrantStateUnspecified || existingUserGrant.State == domain.UserGrantStateRemoved { return nil, zerrors.ThrowNotFound(nil, "COMMAND-3M9sd", "Errors.UserGrant.NotFound") } - + beforeLen := len(existingUserGrant.RoleKeys) existingUserGrant.RoleKeys = slices.DeleteFunc(existingUserGrant.RoleKeys, func(role string) bool { return roleKeysToRemove[role] @@ -302,7 +302,13 @@ func (c *Commands) userGrantWriteModelByID(ctx context.Context, userGrantID stri return writeModel, nil } -func (c *Commands) checkUserGrantPreCondition(ctx context.Context, usergrant *domain.UserGrant, check UserGrantPermissionCheck) (err error) { +type userGrantOwners struct { + userResourceOwner string + projectResourceOwner string + grantedOrg string +} + +func (c *Commands) checkUserGrantPreCondition(ctx context.Context, usergrant *domain.UserGrant, check UserGrantPermissionCheck) (owners userGrantOwners, err error) { if !authz.GetFeatures(ctx).ShouldUseImprovedPerformance(feature.ImprovedPerformanceTypeUserGrant) { return c.checkUserGrantPreConditionOld(ctx, usergrant, check) } @@ -310,13 +316,14 @@ func (c *Commands) checkUserGrantPreCondition(ctx context.Context, usergrant *do ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - if _, err := c.checkUserExists(ctx, usergrant.UserID, ""); err != nil { - return err + userResourceOwner, err := c.checkUserExists(ctx, usergrant.UserID, "") + if err != nil { + return userGrantOwners{}, err } if usergrant.ProjectGrantID != "" || usergrant.ResourceOwner == "" { projectOwner, grantID, err := c.searchProjectOwnerAndGrantID(ctx, usergrant.ProjectID, "") if err != nil { - return err + return userGrantOwners{}, err } if usergrant.ResourceOwner == "" { usergrant.ResourceOwner = projectOwner @@ -324,18 +331,28 @@ func (c *Commands) checkUserGrantPreCondition(ctx context.Context, usergrant *do if usergrant.ProjectGrantID == "" { usergrant.ProjectGrantID = grantID } + owners.projectResourceOwner = projectOwner + } + if owners.projectResourceOwner == "" { + owners.projectResourceOwner = usergrant.ResourceOwner } existingRoleKeys, err := c.searchUserGrantPreConditionState(ctx, usergrant) if err != nil { - return err + return userGrantOwners{}, err } if usergrant.HasInvalidRoles(existingRoleKeys) { - return zerrors.ThrowPreconditionFailed(err, "COMMAND-mm9F4", "Errors.Project.Role.NotFound") + return userGrantOwners{}, zerrors.ThrowPreconditionFailed(err, "COMMAND-mm9F4", "Errors.Project.Role.NotFound") } if check != nil { - return check(usergrant.ProjectID, usergrant.ProjectGrantID)(usergrant.ResourceOwner, "") + if err := check(usergrant.ProjectID, usergrant.ProjectGrantID)(usergrant.ResourceOwner, ""); err != nil { + return userGrantOwners{}, err + } } - return nil + owners.userResourceOwner = userResourceOwner + if usergrant.ProjectGrantID != "" { + owners.grantedOrg = usergrant.ResourceOwner + } + return owners, nil } // this code needs to be rewritten anyways as soon as we improved the fields handling @@ -451,14 +468,14 @@ func (c *Commands) searchUserGrantPreConditionState(ctx context.Context, userGra return existingRoleKeys, nil } -func (c *Commands) checkUserGrantPreConditionOld(ctx context.Context, usergrant *domain.UserGrant, check UserGrantPermissionCheck) (err error) { +func (c *Commands) checkUserGrantPreConditionOld(ctx context.Context, usergrant *domain.UserGrant, check UserGrantPermissionCheck) (owners userGrantOwners, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() preConditions := NewUserGrantPreConditionReadModel(usergrant.UserID, usergrant.ProjectID, usergrant.ProjectGrantID, usergrant.ResourceOwner) err = c.eventstore.FilterToQueryReducer(ctx, preConditions) if err != nil { - return err + return userGrantOwners{}, err } if usergrant.ResourceOwner == "" { usergrant.ResourceOwner = preConditions.ProjectResourceOwner @@ -467,23 +484,30 @@ func (c *Commands) checkUserGrantPreConditionOld(ctx context.Context, usergrant usergrant.ProjectGrantID = preConditions.FoundGrantID } if !preConditions.UserExists { - return zerrors.ThrowPreconditionFailed(err, "COMMAND-4f8sg", "Errors.User.NotFound") + return userGrantOwners{}, zerrors.ThrowPreconditionFailed(err, "COMMAND-4f8sg", "Errors.User.NotFound") } projectIsOwned := usergrant.ResourceOwner == "" || usergrant.ResourceOwner == preConditions.ProjectResourceOwner if projectIsOwned && !preConditions.ProjectExists { - return zerrors.ThrowPreconditionFailed(err, "COMMAND-3n77S", "Errors.Project.NotFound") + return userGrantOwners{}, zerrors.ThrowPreconditionFailed(err, "COMMAND-3n77S", "Errors.Project.NotFound") } if !projectIsOwned && preConditions.FoundGrantID == "" { - return zerrors.ThrowPreconditionFailed(err, "COMMAND-4m9ff", "Errors.Project.Grant.NotFound") + return userGrantOwners{}, zerrors.ThrowPreconditionFailed(err, "COMMAND-4m9ff", "Errors.Project.Grant.NotFound") } // Either check roles from project or project grant if usergrant.HasInvalidRoles(preConditions.existingRoles()) { - return zerrors.ThrowPreconditionFailed(err, "COMMAND-mm9F4", "Errors.Project.Role.NotFound") + return userGrantOwners{}, zerrors.ThrowPreconditionFailed(err, "COMMAND-mm9F4", "Errors.Project.Role.NotFound") } if check != nil { - return check(usergrant.ProjectID, usergrant.ProjectGrantID)(usergrant.ResourceOwner, "") + if err := check(usergrant.ProjectID, usergrant.ProjectGrantID)(usergrant.ResourceOwner, ""); err != nil { + return userGrantOwners{}, err + } } - return nil + owners.userResourceOwner = preConditions.UserResourceOwner + owners.projectResourceOwner = preConditions.ProjectResourceOwner + if usergrant.ProjectGrantID != "" { + owners.grantedOrg = usergrant.ResourceOwner + } + return owners, nil } func (c *Commands) searchProjectOwnerAndGrantID(ctx context.Context, projectID string, grantedOrgID string) (projectOwner string, grantID string, err error) { diff --git a/internal/command/user_grant_model.go b/internal/command/user_grant_model.go index 021767012..ac1c7dee6 100644 --- a/internal/command/user_grant_model.go +++ b/internal/command/user_grant_model.go @@ -95,6 +95,7 @@ type UserGrantPreConditionReadModel struct { ProjectGrantID string FoundGrantID string ResourceOwner string + UserResourceOwner string UserExists bool ProjectExists bool ExistingRoleKeysProject []string @@ -121,12 +122,16 @@ func (wm *UserGrantPreConditionReadModel) Reduce() error { switch e := event.(type) { case *user.HumanAddedEvent: wm.UserExists = true + wm.UserResourceOwner = e.Aggregate().ResourceOwner case *user.HumanRegisteredEvent: wm.UserExists = true + wm.UserResourceOwner = e.Aggregate().ResourceOwner case *user.MachineAddedEvent: wm.UserExists = true + wm.UserResourceOwner = e.Aggregate().ResourceOwner case *user.UserRemovedEvent: wm.UserExists = false + wm.UserResourceOwner = "" case *project.ProjectAddedEvent: if projectExistsOnOrganization(wm.ResourceOwner, e.Aggregate().ResourceOwner) { wm.ProjectExists = true diff --git a/internal/command/user_grant_test.go b/internal/command/user_grant_test.go index faae23f36..5c1d7f02f 100644 --- a/internal/command/user_grant_test.go +++ b/internal/command/user_grant_test.go @@ -462,7 +462,7 @@ func TestCommandSide_AddUserGrant(t *testing.T) { "project1", "", []string{"rolekey1"}, - ), + ).WithOwnerOrgs("org1", "org1", ""), ), ), idGenerator: func(t *testing.T) id.Generator { @@ -550,7 +550,7 @@ func TestCommandSide_AddUserGrant(t *testing.T) { "project1", "", []string{"rolekey1"}, - ), + ).WithOwnerOrgs("org1", "org1", ""), ), ), idGenerator: func(t *testing.T) id.Generator { @@ -631,7 +631,7 @@ func TestCommandSide_AddUserGrant(t *testing.T) { "project1", "projectgrant1", []string{"rolekey1"}, - ), + ).WithOwnerOrgs("org1", "org1", "org1"), ), ), idGenerator: func(t *testing.T) id.Generator { @@ -705,7 +705,7 @@ func TestCommandSide_AddUserGrant(t *testing.T) { "project1", "", []string{"rolekey1"}, - ), + ).WithOwnerOrgs("org1", "org1", ""), ), ), idGenerator: func(t *testing.T) id.Generator { @@ -783,7 +783,7 @@ func TestCommandSide_AddUserGrant(t *testing.T) { "project1", "projectgrant1", []string{"rolekey1"}, - ), + ).WithOwnerOrgs("org1", "org2", "org1"), ), ), idGenerator: func(t *testing.T) id.Generator { @@ -866,7 +866,7 @@ func TestCommandSide_AddUserGrant(t *testing.T) { "project1", "projectgrant1", []string{"rolekey1"}, - ), + ).WithOwnerOrgs("org1", "org1", "org2"), ), ), idGenerator: func(t *testing.T) id.Generator { @@ -3225,31 +3225,31 @@ func TestCommandSide_removeRoleFromUserGrant(t *testing.T) { { name: "advisory scenario, remove two adjacent leading roles", existingRoleKeys: []string{"admin", "viewer", "editor"}, - roleKeysToRemove: map[string]bool{"admin":true, "viewer":true}, + roleKeysToRemove: map[string]bool{"admin": true, "viewer": true}, want: []string{"editor"}, }, { name: "remove first two of four", existingRoleKeys: []string{"a", "b", "c", "d"}, - roleKeysToRemove: map[string]bool{"a":true, "b":true}, + roleKeysToRemove: map[string]bool{"a": true, "b": true}, want: []string{"c", "d"}, }, { name: "remove two adjacent middle roles", existingRoleKeys: []string{"a", "b", "c", "d"}, - roleKeysToRemove: map[string]bool{"b":true, "c":true}, + roleKeysToRemove: map[string]bool{"b": true, "c": true}, want: []string{"a", "d"}, }, { name: "remove two non-adjacent roles", existingRoleKeys: []string{"a", "b", "c", "d"}, - roleKeysToRemove: map[string]bool{"a":true, "c":true}, + roleKeysToRemove: map[string]bool{"a": true, "c": true}, want: []string{"b", "d"}, }, { name: "remove all roles", existingRoleKeys: []string{"x", "y", "z"}, - roleKeysToRemove: map[string]bool{"x":true, "y":true, "z":true}, + roleKeysToRemove: map[string]bool{"x": true, "y": true, "z": true}, want: []string{}, }, } diff --git a/internal/command/user_test.go b/internal/command/user_test.go index 6abc1047a..693798899 100644 --- a/internal/command/user_test.go +++ b/internal/command/user_test.go @@ -6,6 +6,7 @@ import ( "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "golang.org/x/text/language" "github.com/zitadel/zitadel/internal/command/preparation" @@ -1600,6 +1601,53 @@ func TestCommandSide_RemoveUser(t *testing.T) { } } +func TestCommandSide_RemoveUserOwnerDeleteReady(t *testing.T) { + r := &Commands{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + ), + expectFilter(), + expectFilter( + eventFromEventPusher( + instance.NewDomainPolicyAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + true, + true, + true, + ), + ), + ), + expectFilterOrganizationSettings("org1", false, false), + expectPush( + user.NewUserRemovedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "", + nil, + false, + ), + ), + )(t), + ownerDeleteReady: func(context.Context) (bool, error) { return true, nil }, + } + got, err := r.RemoveUser(context.Background(), "user1", "org1", nil, nil, nil) + require.NoError(t, err) + assertObjectDetails(t, &domain.ObjectDetails{ResourceOwner: "org1"}, got) +} + func TestCommands_RevokeAccessToken(t *testing.T) { type fields struct { eventstore func(t *testing.T) *eventstore.Eventstore diff --git a/internal/command/user_v2.go b/internal/command/user_v2.go index 50eb8e7b8..08470b58c 100644 --- a/internal/command/user_v2.go +++ b/internal/command/user_v2.go @@ -152,7 +152,19 @@ func (c *Commands) RemoveUserV2(ctx context.Context, userID, resourceOwner strin } var events []eventstore.Command - events = append(events, user.NewUserRemovedEvent(ctx, &existingUser.Aggregate().Aggregate, existingUser.UserName, existingUser.IDPLinks, domainPolicy.UserLoginMustBeDomain || organizationScopedUsername)) + userName := existingUser.UserName + idpLinks := existingUser.IDPLinks + orgScoped := domainPolicy.UserLoginMustBeDomain || organizationScopedUsername + ownerDeleteReady, err := c.isUniqueConstraintOwnerDeleteReady(ctx) + if err != nil { + return nil, err + } + if ownerDeleteReady { + userName = "" + idpLinks = nil + orgScoped = false + } + events = append(events, user.NewUserRemovedEvent(ctx, &existingUser.Aggregate().Aggregate, userName, idpLinks, orgScoped)) for _, grantID := range cascadingGrantIDs { removeEvent, _, err := c.removeUserGrant(ctx, grantID, "", true, true, nil) diff --git a/internal/eventstore/eventstore_pusher_owners_test.go b/internal/eventstore/eventstore_pusher_owners_test.go new file mode 100644 index 000000000..085a75357 --- /dev/null +++ b/internal/eventstore/eventstore_pusher_owners_test.go @@ -0,0 +1,285 @@ +package eventstore_test + +import ( + "context" + "database/sql" + "testing" + + "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func TestEventstore_Push_UniqueConstraintOwners(t *testing.T) { + for pusherName, pusher := range pushers { + t.Run(pusherName, func(t *testing.T) { + runUniqueConstraintOwnerCases(t, pusher, clients[pusherName]) + }) + } +} + +func runUniqueConstraintOwnerCases(t *testing.T, pusher eventstore.Pusher, client *database.DB) { + t.Helper() + db := eventstore.NewEventstore(&eventstore.Config{ + Querier: queriers["v2(inmemory)"], + Pusher: pusher, + }) + const instanceID = "owners-instance" + for _, tc := range uniqueConstraintOwnerCases { + t.Run(tc.name, func(t *testing.T) { + t.Cleanup(cleanupEventstore(client)) + tc.run(t, db, client, instanceID) + }) + } +} + +type uniqueConstraintOwnerCase struct { + name string + run func(t *testing.T, db *eventstore.Eventstore, client *database.DB, instanceID string) +} + +var uniqueConstraintOwnerCases = []uniqueConstraintOwnerCase{ + { + name: "add stores owners", + run: func(t *testing.T, db *eventstore.Eventstore, client *database.DB, instanceID string) { + _, err := db.Push(context.Background(), generateCommand("owners-add", "1", + withInstanceID(instanceID), + generateAddUniqueConstraint("usernames", "alice", "org:org-1", "user:user-1"), + )) + if err != nil { + t.Fatal(err) + } + assertOwners(t, client, instanceID, "usernames", "alice", []string{"org:org-1", "user:user-1"}) + }, + }, + { + name: "omit owners stores empty", + run: func(t *testing.T, db *eventstore.Eventstore, client *database.DB, instanceID string) { + _, err := db.Push(context.Background(), generateCommand("owners-empty", "1", + withInstanceID(instanceID), + generateAddUniqueConstraint("usernames", "bob"), + )) + if err != nil { + t.Fatal(err) + } + assertOwners(t, client, instanceID, "usernames", "bob", nil) + }, + }, + { + name: "add duplicate already exists", + run: func(t *testing.T, db *eventstore.Eventstore, client *database.DB, instanceID string) { + cmd := func() eventstore.Command { + return generateCommand("owners-dup", "1", + withInstanceID(instanceID), + generateAddUniqueConstraint("usernames", "alice", "org:org-1"), + ) + } + if _, err := db.Push(context.Background(), cmd()); err != nil { + t.Fatal(err) + } + _, err := db.Push(context.Background(), cmd()) + if !zerrors.IsErrorAlreadyExists(err) { + t.Fatalf("expected already exists, got %v", err) + } + }, + }, + { + name: "remove by field case insensitive", + run: func(t *testing.T, db *eventstore.Eventstore, client *database.DB, instanceID string) { + if err := insertUniqueConstraint(client, instanceID, "usernames", "mixedcase", "org:org-1"); err != nil { + t.Fatal(err) + } + _, err := db.Push(context.Background(), generateCommand("owners-field-remove", "1", + withInstanceID(instanceID), + generateRemoveUniqueConstraint("usernames", "MixedCase"), + )) + if err != nil { + t.Fatal(err) + } + assertUniqueCount(t, client, instanceID, 0) + }, + }, + { + name: "remove by owner org", + run: func(t *testing.T, db *eventstore.Eventstore, client *database.DB, instanceID string) { + if err := insertUniqueConstraint(client, instanceID, "usernames", "alice", "org:org-x", "user:u1"); err != nil { + t.Fatal(err) + } + if err := insertUniqueConstraint(client, instanceID, "usernames", "bob", "org:org-y", "user:u2"); err != nil { + t.Fatal(err) + } + _, err := db.Push(context.Background(), generateCommand("owners-org-remove", "1", + withInstanceID(instanceID), + generateRemoveUniqueConstraintsByOwner(eventstore.UniqueConstraintOwnerOrg, "org-x"), + )) + if err != nil { + t.Fatal(err) + } + assertUniqueCount(t, client, instanceID, 1) + assertOwners(t, client, instanceID, "usernames", "bob", []string{"org:org-y", "user:u2"}) + }, + }, + { + name: "remove by owner user", + run: func(t *testing.T, db *eventstore.Eventstore, client *database.DB, instanceID string) { + if err := insertUniqueConstraint(client, instanceID, "usernames", "alice", "org:org-1", "user:u1"); err != nil { + t.Fatal(err) + } + if err := insertUniqueConstraint(client, instanceID, "external_idps", "idp1ext1", "org:org-1", "idp:idp1", "user:u1"); err != nil { + t.Fatal(err) + } + if err := insertUniqueConstraint(client, instanceID, "usernames", "bob", "org:org-1", "user:u2"); err != nil { + t.Fatal(err) + } + _, err := db.Push(context.Background(), generateCommand("owners-user-remove", "1", + withInstanceID(instanceID), + generateRemoveUniqueConstraintsByOwner(eventstore.UniqueConstraintOwnerUser, "u1"), + )) + if err != nil { + t.Fatal(err) + } + assertUniqueCount(t, client, instanceID, 1) + assertOwners(t, client, instanceID, "usernames", "bob", []string{"org:org-1", "user:u2"}) + }, + }, + { + name: "remove by owner idp", + run: func(t *testing.T, db *eventstore.Eventstore, client *database.DB, instanceID string) { + if err := insertUniqueConstraint(client, instanceID, "external_idps", "idp1ext1", "org:org-1", "idp:idp-1", "user:u1"); err != nil { + t.Fatal(err) + } + if err := insertUniqueConstraint(client, instanceID, "external_idps", "idp2ext2", "org:org-1", "idp:idp-2", "user:u2"); err != nil { + t.Fatal(err) + } + _, err := db.Push(context.Background(), generateCommand("owners-idp-remove", "1", + withInstanceID(instanceID), + generateRemoveUniqueConstraintsByOwner(eventstore.UniqueConstraintOwnerIDP, "idp-1"), + )) + if err != nil { + t.Fatal(err) + } + assertUniqueCount(t, client, instanceID, 1) + assertOwners(t, client, instanceID, "external_idps", "idp2ext2", []string{"org:org-1", "idp:idp-2", "user:u2"}) + }, + }, + { + name: "two org tags either org deletes grant", + run: func(t *testing.T, db *eventstore.Eventstore, client *database.DB, instanceID string) { + if err := insertUniqueConstraint(client, instanceID, "project_grant", "granted:project", "org:granting", "org:granted", "project:project"); err != nil { + t.Fatal(err) + } + _, err := db.Push(context.Background(), generateCommand("owners-grant-org", "1", + withInstanceID(instanceID), + generateRemoveUniqueConstraintsByOwner(eventstore.UniqueConstraintOwnerOrg, "granted"), + )) + if err != nil { + t.Fatal(err) + } + assertUniqueCount(t, client, instanceID, 0) + }, + }, + { + name: "instance remove deletes tagged rows", + run: func(t *testing.T, db *eventstore.Eventstore, client *database.DB, instanceID string) { + if err := insertUniqueConstraint(client, instanceID, "usernames", "alice", "org:org-1"); err != nil { + t.Fatal(err) + } + if err := insertUniqueConstraint(client, "other", "usernames", "alice", "org:org-1"); err != nil { + t.Fatal(err) + } + _, err := db.Push(context.Background(), generateCommand("owners-instance-remove", "1", + withInstanceID(instanceID), + generateRemoveInstanceUniqueConstraints(), + )) + if err != nil { + t.Fatal(err) + } + assertUniqueCount(t, client, instanceID, 0) + assertUniqueCount(t, client, "other", 1) + }, + }, + { + name: "global and empty owners not deleted by owner", + run: func(t *testing.T, db *eventstore.Eventstore, client *database.DB, instanceID string) { + if err := insertUniqueConstraint(client, "", "instance_domain", "example.com"); err != nil { + t.Fatal(err) + } + if err := insertUniqueConstraint(client, instanceID, "usernames", "untagged"); err != nil { + t.Fatal(err) + } + if err := insertUniqueConstraint(client, instanceID, "usernames", "tagged", "org:org-1"); err != nil { + t.Fatal(err) + } + _, err := db.Push(context.Background(), generateCommand("owners-skip-empty", "1", + withInstanceID(instanceID), + generateRemoveUniqueConstraintsByOwner(eventstore.UniqueConstraintOwnerOrg, "org-1"), + )) + if err != nil { + t.Fatal(err) + } + assertUniqueCount(t, client, "", 1) + assertUniqueCount(t, client, instanceID, 1) + assertOwners(t, client, instanceID, "usernames", "untagged", nil) + }, + }, + { + name: "mixed batch deletes then inserts", + run: func(t *testing.T, db *eventstore.Eventstore, client *database.DB, instanceID string) { + if err := insertUniqueConstraint(client, instanceID, "usernames", "old", "org:org-1"); err != nil { + t.Fatal(err) + } + if err := insertUniqueConstraint(client, instanceID, "org_name", "acme", "org:org-1"); err != nil { + t.Fatal(err) + } + _, err := db.Push(context.Background(), generateCommand("owners-mixed", "1", + withInstanceID(instanceID), + generateAddUniqueConstraint("usernames", "new", "org:org-1", "user:u1"), + generateRemoveUniqueConstraint("usernames", "old"), + generateRemoveUniqueConstraintsByOwner(eventstore.UniqueConstraintOwnerOrg, "org-1"), + )) + if err != nil { + t.Fatal(err) + } + assertUniqueCount(t, client, instanceID, 1) + assertOwners(t, client, instanceID, "usernames", "new", []string{"org:org-1", "user:u1"}) + }, + }, +} + +func assertUniqueCount(t *testing.T, db *database.DB, instanceID string, want int) { + t.Helper() + var count int + err := db.QueryRow(func(row *sql.Row) error { + return row.Scan(&count) + }, "SELECT COUNT(*) FROM eventstore.unique_constraints WHERE instance_id = $1", instanceID) + if err != nil { + t.Fatal(err) + } + if count != want { + t.Errorf("unique count = %d, want %d", count, want) + } +} + +func assertOwners(t *testing.T, db *database.DB, instanceID, uniqueType, uniqueField string, want []string) { + t.Helper() + var owners database.TextArray[string] + err := db.QueryRow(func(row *sql.Row) error { + return row.Scan(&owners) + }, "SELECT owners FROM eventstore.unique_constraints WHERE instance_id = $1 AND unique_type = $2 AND unique_field = $3", instanceID, uniqueType, uniqueField) + if err != nil { + t.Fatal(err) + } + if len(owners) != len(want) { + t.Fatalf("owners = %v, want %v", []string(owners), want) + } + got := map[string]int{} + for _, owner := range owners { + got[owner]++ + } + for _, owner := range want { + if got[owner] == 0 { + t.Fatalf("owners = %v, want %v", []string(owners), want) + } + got[owner]-- + } +} diff --git a/internal/eventstore/local_postgres_test.go b/internal/eventstore/local_postgres_test.go index 5a9659bef..64e9d8beb 100644 --- a/internal/eventstore/local_postgres_test.go +++ b/internal/eventstore/local_postgres_test.go @@ -206,19 +206,23 @@ func canceledCtx() context.Context { return ctx } -func fillUniqueData(unique_type, field, instanceID string) error { - _, err := testClient.Exec("INSERT INTO eventstore.unique_constraints (unique_type, unique_field, instance_id) VALUES ($1, $2, $3)", unique_type, field, instanceID) +func fillUniqueData(unique_type, field, instanceID string, owners ...string) error { + return insertUniqueConstraint(testClient, instanceID, unique_type, field, owners...) +} + +func insertUniqueConstraint(db *database.DB, instanceID, uniqueType, uniqueField string, owners ...string) error { + if len(owners) == 0 { + _, err := db.Exec("INSERT INTO eventstore.unique_constraints (instance_id, unique_type, unique_field) VALUES ($1, $2, $3)", instanceID, uniqueType, uniqueField) + return err + } + _, err := db.Exec("INSERT INTO eventstore.unique_constraints (instance_id, unique_type, unique_field, owners) VALUES ($1, $2, $3, $4)", instanceID, uniqueType, uniqueField, database.TextArray[string](owners)) return err } -func generateAddUniqueConstraint(table, uniqueField string) func(e *testEvent) { +func generateAddUniqueConstraint(table, uniqueField string, owners ...string) func(e *testEvent) { return func(e *testEvent) { e.uniqueConstraints = append(e.uniqueConstraints, - &eventstore.UniqueConstraint{ - UniqueType: table, - UniqueField: uniqueField, - Action: eventstore.UniqueConstraintAdd, - }, + eventstore.NewAddEventUniqueConstraint(table, uniqueField, "").WithOwners(owners...), ) } } @@ -235,6 +239,24 @@ func generateRemoveUniqueConstraint(table, uniqueField string) func(e *testEvent } } +func generateRemoveInstanceUniqueConstraints() func(e *testEvent) { + return func(e *testEvent) { + e.uniqueConstraints = append(e.uniqueConstraints, eventstore.NewRemoveInstanceUniqueConstraints()) + } +} + +func generateRemoveUniqueConstraintsByOwner(kind, id string) func(e *testEvent) { + return func(e *testEvent) { + e.uniqueConstraints = append(e.uniqueConstraints, eventstore.NewRemoveUniqueConstraintsByOwner(kind, id)) + } +} + +func withInstanceID(instanceID string) func(e *testEvent) { + return func(e *testEvent) { + e.Agg.InstanceID = instanceID + } +} + func withEventType(eventType eventstore.EventType) func(e *testEvent) { return func(e *testEvent) { e.EventType = eventType diff --git a/internal/eventstore/unique_constraint.go b/internal/eventstore/unique_constraint.go index 4486e19e5..0c0af463c 100644 --- a/internal/eventstore/unique_constraint.go +++ b/internal/eventstore/unique_constraint.go @@ -11,6 +11,8 @@ type UniqueConstraint struct { ErrorMessage string // IsGlobal defines if the unique constraint is globally unique or just within a single instance IsGlobal bool + // Owners is a bag of kind:id tags used for bulk lifecycle deletes + Owners []string } type UniqueConstraintAction int8 @@ -19,14 +21,70 @@ const ( UniqueConstraintAdd UniqueConstraintAction = iota UniqueConstraintRemove UniqueConstraintInstanceRemove + UniqueConstraintRemoveByOwner uniqueConstraintActionCount ) +const ( + UniqueConstraintOwnerOrg = "org" + UniqueConstraintOwnerUser = "user" + UniqueConstraintOwnerIDP = "idp" + UniqueConstraintOwnerProject = "project" + UniqueConstraintOwnerGrant = "grant" + + // UniqueConstraintOwnersBackfillStep is the setup migration that stamps owners from projections. + UniqueConstraintOwnersBackfillStep = "79_backfill_unique_constraint_owners" +) + +// UniqueTypesWithOwners are unique_types that live writes and setup 79 stamp with owner tags. +// Instance mail_text shares unique_type "mail_text" but stays empty by design, so it is omitted. +var UniqueTypesWithOwners = []string{ + "usernames", + "external_idps", + "org_name", + "org_domain", + "project_names", + "appname", + "project_role", + "entity_ids", + "project_grant", + "project_grant_member", + "user_grant", + "member", + "group_name", + "action_names", + "idp_config_names", +} + func (f UniqueConstraintAction) Valid() bool { return f >= 0 && f < uniqueConstraintActionCount } +func OwnerTag(kind, id string) string { + if kind == "" || id == "" { + return "" + } + return kind + ":" + id +} + +func (u *UniqueConstraint) WithOwners(tags ...string) *UniqueConstraint { + owners := make([]string, 0, len(tags)) + seen := make(map[string]struct{}, len(tags)) + for _, tag := range tags { + if tag == "" { + continue + } + if _, ok := seen[tag]; ok { + continue + } + seen[tag] = struct{}{} + owners = append(owners, tag) + } + u.Owners = owners + return u +} + func NewAddEventUniqueConstraint( uniqueType, uniqueField, @@ -36,6 +94,7 @@ func NewAddEventUniqueConstraint( UniqueField: uniqueField, ErrorMessage: errMessage, Action: UniqueConstraintAdd, + Owners: []string{}, } } @@ -55,6 +114,16 @@ func NewRemoveInstanceUniqueConstraints() *UniqueConstraint { } } +func NewRemoveUniqueConstraintsByOwner(kind, id string) *UniqueConstraint { + constraint := &UniqueConstraint{ + Action: UniqueConstraintRemoveByOwner, + } + if tag := OwnerTag(kind, id); tag != "" { + constraint.Owners = []string{tag} + } + return constraint +} + func NewAddGlobalUniqueConstraint( uniqueType, uniqueField, @@ -65,6 +134,7 @@ func NewAddGlobalUniqueConstraint( ErrorMessage: errMessage, IsGlobal: true, Action: UniqueConstraintAdd, + Owners: []string{}, } } diff --git a/internal/eventstore/unique_constraint_test.go b/internal/eventstore/unique_constraint_test.go new file mode 100644 index 000000000..32d40bf9f --- /dev/null +++ b/internal/eventstore/unique_constraint_test.go @@ -0,0 +1,74 @@ +package eventstore + +import "testing" + +func TestOwnerTag(t *testing.T) { + tests := []struct { + name string + kind string + id string + want string + }{ + {name: "org", kind: UniqueConstraintOwnerOrg, id: "123", want: "org:123"}, + {name: "empty id", kind: UniqueConstraintOwnerOrg, id: "", want: ""}, + {name: "empty kind", kind: "", id: "123", want: ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := OwnerTag(tt.kind, tt.id); got != tt.want { + t.Errorf("OwnerTag() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestUniqueConstraintAction_Valid(t *testing.T) { + if !UniqueConstraintAdd.Valid() { + t.Error("UniqueConstraintAdd should be valid") + } + if !UniqueConstraintRemove.Valid() { + t.Error("UniqueConstraintRemove should be valid") + } + if !UniqueConstraintInstanceRemove.Valid() { + t.Error("UniqueConstraintInstanceRemove should be valid") + } + if !UniqueConstraintRemoveByOwner.Valid() { + t.Error("UniqueConstraintRemoveByOwner should be valid") + } + if uniqueConstraintActionCount.Valid() { + t.Error("uniqueConstraintActionCount should be invalid") + } +} + +func TestUniqueConstraint_WithOwners(t *testing.T) { + got := NewAddEventUniqueConstraint("usernames", "alice", "err").WithOwners( + OwnerTag(UniqueConstraintOwnerOrg, "org1"), + "", + OwnerTag(UniqueConstraintOwnerUser, "user1"), + OwnerTag(UniqueConstraintOwnerOrg, "org1"), + ) + if len(got.Owners) != 2 || got.Owners[0] != "org:org1" || got.Owners[1] != "user:user1" { + t.Errorf("unexpected owners: %#v", got.Owners) + } + remove := NewRemoveUniqueConstraintsByOwner(UniqueConstraintOwnerOrg, "org1") + if remove.Action != UniqueConstraintRemoveByOwner { + t.Error("expected UniqueConstraintRemoveByOwner") + } + if len(remove.Owners) != 1 || remove.Owners[0] != "org:org1" { + t.Errorf("unexpected remove owners: %#v", remove.Owners) + } + if empty := NewRemoveUniqueConstraintsByOwner("", "org1"); len(empty.Owners) != 0 { + t.Errorf("empty kind should not stamp owners: %#v", empty.Owners) + } +} + +func TestUniqueTypesWithOwners(t *testing.T) { + if len(UniqueTypesWithOwners) == 0 { + t.Fatal("UniqueTypesWithOwners must not be empty") + } + for _, typ := range UniqueTypesWithOwners { + if typ == "mail_text" { + t.Fatal("mail_text must be omitted: instance rows stay empty") + } + } +} diff --git a/internal/eventstore/v3/unique_constraints.go b/internal/eventstore/v3/unique_constraints.go index 08c640d46..d29589df8 100644 --- a/internal/eventstore/v3/unique_constraints.go +++ b/internal/eventstore/v3/unique_constraints.go @@ -46,8 +46,8 @@ func handleUniqueConstraints(ctx context.Context, tx database.Transaction, comma switch constraint.Action { case eventstore.UniqueConstraintAdd: constraint.UniqueField = strings.ToLower(constraint.UniqueField) - addPlaceholders = append(addPlaceholders, fmt.Sprintf("($%d, $%d, $%d)", len(addArgs)+1, len(addArgs)+2, len(addArgs)+3)) - addArgs = append(addArgs, instanceID, constraint.UniqueType, constraint.UniqueField) + addPlaceholders = append(addPlaceholders, fmt.Sprintf("($%d, $%d, $%d, $%d)", len(addArgs)+1, len(addArgs)+2, len(addArgs)+3, len(addArgs)+4)) + addArgs = append(addArgs, instanceID, constraint.UniqueType, constraint.UniqueField, constraint.Owners) addConstraints[fmt.Sprintf(uniqueConstraintPlaceholderFmt, instanceID, constraint.UniqueType, constraint.UniqueField)] = constraint case eventstore.UniqueConstraintRemove: deletePlaceholders = append(deletePlaceholders, fmt.Sprintf(deleteConstraintPlaceholdersStmt, len(deleteArgs)+1, len(deleteArgs)+2, len(deleteArgs)+3)) @@ -57,6 +57,14 @@ func handleUniqueConstraints(ctx context.Context, tx database.Transaction, comma deletePlaceholders = append(deletePlaceholders, fmt.Sprintf("(instance_id = $%d)", len(deleteArgs)+1)) deleteArgs = append(deleteArgs, instanceID) deleteConstraints[fmt.Sprintf(uniqueConstraintPlaceholderFmt, instanceID, constraint.UniqueType, constraint.UniqueField)] = constraint + case eventstore.UniqueConstraintRemoveByOwner: + if len(constraint.Owners) == 0 { + continue + } + tag := constraint.Owners[0] + deletePlaceholders = append(deletePlaceholders, fmt.Sprintf("(instance_id = $%d AND owners @> ARRAY[$%d]::text[])", len(deleteArgs)+1, len(deleteArgs)+2)) + deleteArgs = append(deleteArgs, instanceID, tag) + deleteConstraints[fmt.Sprintf("%s:%s", instanceID, tag)] = constraint } } } diff --git a/internal/eventstore/v3/unique_constraints_add.sql b/internal/eventstore/v3/unique_constraints_add.sql index 1d0d9e5be..6c9e07467 100644 --- a/internal/eventstore/v3/unique_constraints_add.sql +++ b/internal/eventstore/v3/unique_constraints_add.sql @@ -2,5 +2,6 @@ INSERT INTO eventstore.unique_constraints ( instance_id , unique_type , unique_field + , owners ) VALUES - %s \ No newline at end of file + %s diff --git a/internal/migration/migration.go b/internal/migration/migration.go index cc5c9f000..e5541ea89 100644 --- a/internal/migration/migration.go +++ b/internal/migration/migration.go @@ -149,6 +149,10 @@ func shouldExec(ctx context.Context, es *eventstore.Eventstore, migration Migrat return true, nil } if step.state == StepFailed { + if repeatable, ok := migration.(RepeatableMigration); ok { + lastRun, _ := step.LastRun.(map[string]interface{}) + repeatable.Check(lastRun) + } return true, nil } if step.state == StepStarted { diff --git a/internal/migration/should_exec_test.go b/internal/migration/should_exec_test.go new file mode 100644 index 000000000..2c6456870 --- /dev/null +++ b/internal/migration/should_exec_test.go @@ -0,0 +1,74 @@ +package migration + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/eventstore/repository" + "github.com/zitadel/zitadel/internal/eventstore/repository/mock" +) + +type checkRecordingMigration struct { + name string + checkCalls int + lastRun map[string]any +} + +func (m *checkRecordingMigration) String() string { return m.name } +func (m *checkRecordingMigration) Execute(context.Context, eventstore.Event) error { + return nil +} +func (m *checkRecordingMigration) Check(lastRun map[string]any) bool { + m.checkCalls++ + m.lastRun = lastRun + return false +} + +func TestShouldExec_failedRepeatableCallsCheckAndRetries(t *testing.T) { + mig := &checkRecordingMigration{name: "repeatable-step"} + done := migrationSetupEvent(t, mig.name, repeatableDoneType, map[string]any{"version": "v1.0.0", "finalized": false}) + failed := migrationSetupEvent(t, mig.name, failedType, map[string]any{"version": "v2.0.0", "finalized": true}) + + repo := mock.NewRepo(t) + repo.ExpectFilterEvents(done, failed) + es := eventstore.NewEventstore(&eventstore.Config{ + Querier: repo.MockQuerier, + Pusher: repo.MockPusher, + }) + + should, err := shouldExec(t.Context(), es, mig) + require.NoError(t, err) + assert.True(t, should) + assert.Equal(t, 1, mig.checkCalls) + require.NotNil(t, mig.lastRun) + assert.Equal(t, "v1.0.0", mig.lastRun["version"]) + assert.Equal(t, false, mig.lastRun["finalized"]) +} + +func migrationSetupEvent(t *testing.T, name string, typ eventstore.EventType, lastRun map[string]any) *repository.Event { + t.Helper() + cmd := &SetupStep{ + BaseEvent: *eventstore.NewBaseEventForPush( + t.Context(), + eventstore.NewAggregate(t.Context(), SystemAggregateID, SystemAggregate, "v1"), + typ, + ), + Name: name, + LastRun: lastRun, + } + data, err := eventstore.EventData(cmd) + require.NoError(t, err) + return &repository.Event{ + InstanceID: cmd.Aggregate().InstanceID, + Typ: cmd.Type(), + Data: data, + EditorUser: cmd.Creator(), + Version: cmd.Aggregate().Version, + AggregateID: cmd.Aggregate().ID, + AggregateType: cmd.Aggregate().Type, + } +} diff --git a/internal/migration/step.go b/internal/migration/step.go index 04cae07e3..e9ec340b3 100644 --- a/internal/migration/step.go +++ b/internal/migration/step.go @@ -35,15 +35,20 @@ func (s *StepStates) Reduce() error { state = new(Step) s.Steps = append(s.Steps, state) } - state.SetupStep = step switch step.EventType { case StartedType: + state.SetupStep = step state.state = StepStarted case DoneType: + state.SetupStep = step state.state = StepDone case repeatableDoneType: + state.SetupStep = step state.state = StepDone case failedType: + if state.SetupStep == nil { + state.SetupStep = step + } state.state = StepFailed } } diff --git a/internal/migration/step_test.go b/internal/migration/step_test.go index 3df34c5a2..dcbd076a1 100644 --- a/internal/migration/step_test.go +++ b/internal/migration/step_test.go @@ -5,9 +5,38 @@ import ( "testing" "time" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zitadel/zitadel/internal/eventstore" ) +func TestStepStates_Reduce_failedKeepsLastRun(t *testing.T) { + agg := eventstore.NewAggregate(t.Context(), SystemAggregateID, SystemAggregate, "v1") + done := &SetupStep{ + BaseEvent: eventstore.BaseEvent{EventType: repeatableDoneType, Agg: agg}, + Name: "79_backfill_unique_constraint_owners", + LastRun: map[string]any{"version": "v1.0.0", "finalized": false}, + } + failed := &SetupStep{ + BaseEvent: eventstore.BaseEvent{EventType: failedType, Agg: agg}, + Name: "79_backfill_unique_constraint_owners", + LastRun: map[string]any{"version": "v2.0.0", "finalized": true}, + } + + states := &StepStates{} + states.AppendEvents(done, failed) + require.NoError(t, states.Reduce()) + + step := states.byName("79_backfill_unique_constraint_owners") + require.NotNil(t, step) + assert.Equal(t, StepFailed, step.state) + lastRun, ok := step.LastRun.(map[string]any) + require.True(t, ok) + assert.Equal(t, "v1.0.0", lastRun["version"]) + assert.Equal(t, false, lastRun["finalized"]) +} + func TestStepStates_lastByState(t *testing.T) { now := time.Now() past := now.Add(-10 * time.Millisecond) diff --git a/internal/repository/action/action.go b/internal/repository/action/action.go index 647d867a8..1a3f7ac87 100644 --- a/internal/repository/action/action.go +++ b/internal/repository/action/action.go @@ -47,7 +47,7 @@ func (e *AddedEvent) Payload() interface{} { } func (e *AddedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { - return []*eventstore.UniqueConstraint{NewAddActionNameUniqueConstraint(e.Name, e.Aggregate().ResourceOwner)} + return []*eventstore.UniqueConstraint{NewAddActionNameUniqueConstraint(e.Name, e.Aggregate().ResourceOwner).WithOwners(eventstore.OwnerTag(eventstore.UniqueConstraintOwnerOrg, e.Aggregate().ResourceOwner))} } func NewAddedEvent( @@ -104,7 +104,7 @@ func (e *ChangedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { } return []*eventstore.UniqueConstraint{ NewRemoveActionNameUniqueConstraint(e.oldName, e.Aggregate().ResourceOwner), - NewAddActionNameUniqueConstraint(*e.Name, e.Aggregate().ResourceOwner), + NewAddActionNameUniqueConstraint(*e.Name, e.Aggregate().ResourceOwner).WithOwners(eventstore.OwnerTag(eventstore.UniqueConstraintOwnerOrg, e.Aggregate().ResourceOwner)), } } diff --git a/internal/repository/group/group.go b/internal/repository/group/group.go index 575bf97a4..25b09368c 100644 --- a/internal/repository/group/group.go +++ b/internal/repository/group/group.go @@ -47,7 +47,7 @@ func (g *GroupAddedEvent) Payload() any { } func (g *GroupAddedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { - return []*eventstore.UniqueConstraint{NewAddGroupNameUniqueConstraint(g.Name, g.Aggregate().ResourceOwner)} + return []*eventstore.UniqueConstraint{NewAddGroupNameUniqueConstraint(g.Name, g.Aggregate().ResourceOwner).WithOwners(eventstore.OwnerTag(eventstore.UniqueConstraintOwnerOrg, g.Aggregate().ResourceOwner))} } func NewAddGroupNameUniqueConstraint(groupName, organizationID string) *eventstore.UniqueConstraint { @@ -121,7 +121,7 @@ func (g *GroupChangedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { } return []*eventstore.UniqueConstraint{ NewRemoveGroupNameUniqueConstraint(g.oldName, g.Aggregate().ResourceOwner), - NewAddGroupNameUniqueConstraint(*g.Name, g.Aggregate().ResourceOwner)} + NewAddGroupNameUniqueConstraint(*g.Name, g.Aggregate().ResourceOwner).WithOwners(eventstore.OwnerTag(eventstore.UniqueConstraintOwnerOrg, g.Aggregate().ResourceOwner))} } type GroupRemovedEvent struct { diff --git a/internal/repository/idpconfig/idp_config.go b/internal/repository/idpconfig/idp_config.go index f6cdc748c..8c8e60071 100644 --- a/internal/repository/idpconfig/idp_config.go +++ b/internal/repository/idpconfig/idp_config.go @@ -55,8 +55,15 @@ func (e *IDPConfigAddedEvent) Payload() interface{} { return e } +func idpConfigOwnerTags(agg *eventstore.Aggregate, configID string) []string { + return []string{ + eventstore.OwnerTag(eventstore.UniqueConstraintOwnerOrg, agg.ResourceOwner), + eventstore.OwnerTag(eventstore.UniqueConstraintOwnerIDP, configID), + } +} + func (e *IDPConfigAddedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { - return []*eventstore.UniqueConstraint{NewAddIDPConfigNameUniqueConstraint(e.Name, e.Aggregate().ResourceOwner)} + return []*eventstore.UniqueConstraint{NewAddIDPConfigNameUniqueConstraint(e.Name, e.Aggregate().ResourceOwner).WithOwners(idpConfigOwnerTags(e.Aggregate(), e.ConfigID)...)} } func IDPConfigAddedEventMapper(event eventstore.Event) (eventstore.Event, error) { @@ -92,7 +99,7 @@ func (e *IDPConfigChangedEvent) UniqueConstraints() []*eventstore.UniqueConstrai } return []*eventstore.UniqueConstraint{ NewRemoveIDPConfigNameUniqueConstraint(e.oldName, e.Aggregate().ResourceOwner), - NewAddIDPConfigNameUniqueConstraint(*e.Name, e.Aggregate().ResourceOwner), + NewAddIDPConfigNameUniqueConstraint(*e.Name, e.Aggregate().ResourceOwner).WithOwners(idpConfigOwnerTags(e.Aggregate(), e.ConfigID)...), } } @@ -250,7 +257,10 @@ func (e *IDPConfigRemovedEvent) Payload() interface{} { } func (e *IDPConfigRemovedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { - return []*eventstore.UniqueConstraint{NewRemoveIDPConfigNameUniqueConstraint(e.name, e.Aggregate().ResourceOwner)} + return []*eventstore.UniqueConstraint{ + NewRemoveIDPConfigNameUniqueConstraint(e.name, e.Aggregate().ResourceOwner), + eventstore.NewRemoveUniqueConstraintsByOwner(eventstore.UniqueConstraintOwnerIDP, e.ConfigID), + } } func IDPConfigRemovedEventMapper(event eventstore.Event) (eventstore.Event, error) { diff --git a/internal/repository/instance/member.go b/internal/repository/instance/member.go index 161bdcdae..4401e6833 100644 --- a/internal/repository/instance/member.go +++ b/internal/repository/instance/member.go @@ -46,6 +46,11 @@ func NewMemberAddedEvent( } } +func (e *MemberAddedEvent) WithUserResourceOwner(userResourceOwner string) *MemberAddedEvent { + e.MemberAddedEvent.WithUserResourceOwner(userResourceOwner) + return e +} + func MemberAddedEventMapper(event eventstore.Event) (eventstore.Event, error) { e, err := member.MemberAddedEventMapper(event) if err != nil { diff --git a/internal/repository/member/events.go b/internal/repository/member/events.go index 5d0a28c24..73553d2a1 100644 --- a/internal/repository/member/events.go +++ b/internal/repository/member/events.go @@ -14,6 +14,9 @@ const ( ChangedEventType = "member.changed" RemovedEventType = "member.removed" CascadeRemovedEventType = "member.cascade.removed" + + orgAggregateType eventstore.AggregateType = "org" + projectAggregateType eventstore.AggregateType = "project" ) // Field table and unique types @@ -42,14 +45,38 @@ type MemberAddedEvent struct { Roles []string `json:"roles"` UserID string `json:"userId"` + // userResourceOwner is the user's home org. It is not persisted in the event payload. + userResourceOwner string } func (e *MemberAddedEvent) Payload() interface{} { return e } +func memberOwnerTags(agg *eventstore.Aggregate, userID, userResourceOwner string) []string { + tags := []string{ + eventstore.OwnerTag(eventstore.UniqueConstraintOwnerUser, userID), + eventstore.OwnerTag(eventstore.UniqueConstraintOwnerOrg, userResourceOwner), + } + switch agg.Type { + case projectAggregateType: + tags = append(tags, + eventstore.OwnerTag(eventstore.UniqueConstraintOwnerOrg, agg.ResourceOwner), + eventstore.OwnerTag(eventstore.UniqueConstraintOwnerProject, agg.ID), + ) + case orgAggregateType: + tags = append(tags, eventstore.OwnerTag(eventstore.UniqueConstraintOwnerOrg, agg.ID)) + } + return tags +} + func (e *MemberAddedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { - return []*eventstore.UniqueConstraint{NewAddMemberUniqueConstraint(e.Aggregate().ID, e.UserID)} + return []*eventstore.UniqueConstraint{NewAddMemberUniqueConstraint(e.Aggregate().ID, e.UserID).WithOwners(memberOwnerTags(e.Aggregate(), e.UserID, e.userResourceOwner)...)} +} + +func (e *MemberAddedEvent) WithUserResourceOwner(userResourceOwner string) *MemberAddedEvent { + e.userResourceOwner = userResourceOwner + return e } func (e *MemberAddedEvent) FieldOperations(prefix string) []*eventstore.FieldOperation { diff --git a/internal/repository/org/domain.go b/internal/repository/org/domain.go index 85b8a939f..f42001d84 100644 --- a/internal/repository/org/domain.go +++ b/internal/repository/org/domain.go @@ -191,7 +191,7 @@ func (e *DomainVerifiedEvent) Payload() interface{} { } func (e *DomainVerifiedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { - return []*eventstore.UniqueConstraint{NewAddOrgDomainUniqueConstraint(e.Domain)} + return []*eventstore.UniqueConstraint{NewAddOrgDomainUniqueConstraint(e.Domain).WithOwners(eventstore.OwnerTag(eventstore.UniqueConstraintOwnerOrg, e.Aggregate().ID))} } func (e *DomainVerifiedEvent) Fields() []*eventstore.FieldOperation { diff --git a/internal/repository/org/member.go b/internal/repository/org/member.go index 5068a274b..e76e62291 100644 --- a/internal/repository/org/member.go +++ b/internal/repository/org/member.go @@ -45,6 +45,11 @@ func NewMemberAddedEvent( } } +func (e *MemberAddedEvent) WithUserResourceOwner(userResourceOwner string) *MemberAddedEvent { + e.MemberAddedEvent.WithUserResourceOwner(userResourceOwner) + return e +} + func MemberAddedEventMapper(event eventstore.Event) (eventstore.Event, error) { e, err := member.MemberAddedEventMapper(event) if err != nil { diff --git a/internal/repository/org/org.go b/internal/repository/org/org.go index 562cbd85f..860b5de8c 100644 --- a/internal/repository/org/org.go +++ b/internal/repository/org/org.go @@ -47,7 +47,7 @@ func (e *OrgAddedEvent) Payload() interface{} { } func (e *OrgAddedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { - return []*eventstore.UniqueConstraint{NewAddOrgNameUniqueConstraint(e.Name)} + return []*eventstore.UniqueConstraint{NewAddOrgNameUniqueConstraint(e.Name).WithOwners(eventstore.OwnerTag(eventstore.UniqueConstraintOwnerOrg, e.Aggregate().ID))} } func (e *OrgAddedEvent) Fields() []*eventstore.FieldOperation { @@ -124,7 +124,7 @@ func (e *OrgChangedEvent) Payload() interface{} { func (e *OrgChangedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { return []*eventstore.UniqueConstraint{ NewRemoveOrgNameUniqueConstraint(e.oldName), - NewAddOrgNameUniqueConstraint(e.Name), + NewAddOrgNameUniqueConstraint(e.Name).WithOwners(eventstore.OwnerTag(eventstore.UniqueConstraintOwnerOrg, e.Aggregate().ID)), } } @@ -291,6 +291,7 @@ func (e *OrgRemovedEvent) Payload() interface{} { func (e *OrgRemovedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { constraints := []*eventstore.UniqueConstraint{ NewRemoveOrgNameUniqueConstraint(e.name), + eventstore.NewRemoveUniqueConstraintsByOwner(eventstore.UniqueConstraintOwnerOrg, e.Aggregate().ID), } for _, name := range e.usernames { constraints = append(constraints, user.NewRemoveUsernameUniqueConstraint(name, e.Aggregate().ID, e.organizationScopedUsernames)) diff --git a/internal/repository/organization_settings/organization.go b/internal/repository/organization_settings/organization.go index c157316ba..c4de840d1 100644 --- a/internal/repository/organization_settings/organization.go +++ b/internal/repository/organization_settings/organization.go @@ -18,7 +18,7 @@ type OrganizationSettingsSetEvent struct { OrganizationScopedUsernames bool `json:"organizationScopedUsernames,omitempty"` oldOrganizationScopedUsernames bool - usernameChanges []string + usernameChanges []user.UsernameChange } func (e *OrganizationSettingsSetEvent) SetBaseEvent(b *eventstore.BaseEvent) { @@ -30,21 +30,13 @@ func (e *OrganizationSettingsSetEvent) Payload() any { } func (e *OrganizationSettingsSetEvent) UniqueConstraints() []*eventstore.UniqueConstraint { - if len(e.usernameChanges) == 0 || e.oldOrganizationScopedUsernames == e.OrganizationScopedUsernames { - return []*eventstore.UniqueConstraint{} - } - changes := make([]*eventstore.UniqueConstraint, len(e.usernameChanges)*2) - for i, username := range e.usernameChanges { - changes[i*2] = user.NewRemoveUsernameUniqueConstraint(username, e.Aggregate().ResourceOwner, e.oldOrganizationScopedUsernames) - changes[i*2+1] = user.NewAddUsernameUniqueConstraint(username, e.Aggregate().ResourceOwner, e.OrganizationScopedUsernames) - } - return changes + return user.NewUsernameUniqueConstraints(e.usernameChanges, e.Aggregate().ResourceOwner, e.OrganizationScopedUsernames, e.oldOrganizationScopedUsernames) } func NewOrganizationSettingsAddedEvent( ctx context.Context, aggregate *eventstore.Aggregate, - usernameChanges []string, + usernameChanges []user.UsernameChange, organizationScopedUsernames bool, oldOrganizationScopedUsernames bool, ) *OrganizationSettingsSetEvent { @@ -63,7 +55,7 @@ type OrganizationSettingsRemovedEvent struct { organizationScopedUsernames bool oldOrganizationScopedUsernames bool - usernameChanges []string + usernameChanges []user.UsernameChange } func (e *OrganizationSettingsRemovedEvent) SetBaseEvent(b *eventstore.BaseEvent) { @@ -81,7 +73,7 @@ func (e *OrganizationSettingsRemovedEvent) UniqueConstraints() []*eventstore.Uni func NewOrganizationSettingsRemovedEvent( ctx context.Context, aggregate *eventstore.Aggregate, - usernameChanges []string, + usernameChanges []user.UsernameChange, organizationScopedUsernames bool, oldOrganizationScopedUsernames bool, ) *OrganizationSettingsRemovedEvent { diff --git a/internal/repository/policy/mail_text.go b/internal/repository/policy/mail_text.go index 98ab93636..e5a9e3a30 100644 --- a/internal/repository/policy/mail_text.go +++ b/internal/repository/policy/mail_text.go @@ -13,6 +13,8 @@ const ( MailTextPolicyAddedEventType = mailTextPolicyPrefix + "added" MailTextPolicyChangedEventType = mailTextPolicyPrefix + "changed" MailTextPolicyRemovedEventType = mailTextPolicyPrefix + "removed" + + mailTextOrgAggregateType eventstore.AggregateType = "org" ) func NewAddMailTextUniqueConstraint(aggregateID, mailTextType, langugage string) *eventstore.UniqueConstraint { @@ -28,6 +30,13 @@ func NewRemoveMailTextUniqueConstraint(aggregateID, mailTextType, langugage stri fmt.Sprintf("%v:%v:%v", aggregateID, mailTextType, langugage)) } +func mailTextOwnerTags(agg *eventstore.Aggregate) []string { + if agg.Type != mailTextOrgAggregateType { + return nil + } + return []string{eventstore.OwnerTag(eventstore.UniqueConstraintOwnerOrg, agg.ResourceOwner)} +} + type MailTextAddedEvent struct { eventstore.BaseEvent `json:"-"` @@ -46,7 +55,7 @@ func (e *MailTextAddedEvent) Payload() interface{} { } func (e *MailTextAddedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { - return []*eventstore.UniqueConstraint{NewAddMailTextUniqueConstraint(e.Aggregate().ResourceOwner, e.MailTextType, e.Language)} + return []*eventstore.UniqueConstraint{NewAddMailTextUniqueConstraint(e.Aggregate().ResourceOwner, e.MailTextType, e.Language).WithOwners(mailTextOwnerTags(e.Aggregate())...)} } func NewMailTextAddedEvent( diff --git a/internal/repository/policy/policy_domain.go b/internal/repository/policy/policy_domain.go index ad459625b..9805f3eb8 100644 --- a/internal/repository/policy/policy_domain.go +++ b/internal/repository/policy/policy_domain.go @@ -124,7 +124,7 @@ func DomainPolicyChangedEventMapper(event eventstore.Event) (eventstore.Event, e type DomainPolicyRemovedEvent struct { eventstore.BaseEvent `json:"-"` - usernameChanges []string + usernameChanges []user.UsernameChange userLoginMustBeDomain bool oldUserLoginMustBeDomain bool } @@ -149,7 +149,7 @@ func DomainPolicyRemovedEventMapper(event eventstore.Event) (eventstore.Event, e }, nil } -func (e *DomainPolicyRemovedEvent) AddUniqueConstraintChanges(usernameChanges []string, userLoginMustBeDomain, oldUserLoginMustBeDomain bool) { +func (e *DomainPolicyRemovedEvent) AddUniqueConstraintChanges(usernameChanges []user.UsernameChange, userLoginMustBeDomain, oldUserLoginMustBeDomain bool) { e.usernameChanges = usernameChanges e.userLoginMustBeDomain = userLoginMustBeDomain e.oldUserLoginMustBeDomain = oldUserLoginMustBeDomain diff --git a/internal/repository/project/application.go b/internal/repository/project/application.go index cb6dea156..24b2d527c 100644 --- a/internal/repository/project/application.go +++ b/internal/repository/project/application.go @@ -43,7 +43,7 @@ func (e *ApplicationAddedEvent) Payload() interface{} { } func (e *ApplicationAddedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { - return []*eventstore.UniqueConstraint{NewAddApplicationUniqueConstraint(e.Name, e.Aggregate().ID)} + return []*eventstore.UniqueConstraint{NewAddApplicationUniqueConstraint(e.Name, e.Aggregate().ID).WithOwners(projectOwnerTags(e.Aggregate())...)} } func NewApplicationAddedEvent( @@ -91,7 +91,7 @@ func (e *ApplicationChangedEvent) Payload() interface{} { func (e *ApplicationChangedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { return []*eventstore.UniqueConstraint{ NewRemoveApplicationUniqueConstraint(e.oldName, e.Aggregate().ID), - NewAddApplicationUniqueConstraint(e.Name, e.Aggregate().ID), + NewAddApplicationUniqueConstraint(e.Name, e.Aggregate().ID).WithOwners(projectOwnerTags(e.Aggregate())...), } } diff --git a/internal/repository/project/grant.go b/internal/repository/project/grant.go index a78172a03..cf9b82719 100644 --- a/internal/repository/project/grant.go +++ b/internal/repository/project/grant.go @@ -53,7 +53,7 @@ func (e *GrantAddedEvent) Payload() interface{} { } func (e *GrantAddedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { - return []*eventstore.UniqueConstraint{NewAddProjectGrantUniqueConstraint(e.GrantedOrgID, e.Aggregate().ID)} + return []*eventstore.UniqueConstraint{NewAddProjectGrantUniqueConstraint(e.GrantedOrgID, e.Aggregate().ID).WithOwners(grantOwnerTags(e.Aggregate(), e.GrantID, e.GrantedOrgID)...)} } func (e *GrantAddedEvent) Fields() []*eventstore.FieldOperation { @@ -458,7 +458,10 @@ func (e *GrantRemovedEvent) Payload() interface{} { } func (e *GrantRemovedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { - return []*eventstore.UniqueConstraint{NewRemoveProjectGrantUniqueConstraint(e.grantedOrgID, e.Aggregate().ID)} + return []*eventstore.UniqueConstraint{ + NewRemoveProjectGrantUniqueConstraint(e.grantedOrgID, e.Aggregate().ID), + eventstore.NewRemoveUniqueConstraintsByOwner(eventstore.UniqueConstraintOwnerGrant, e.GrantID), + } } func (e *GrantRemovedEvent) Fields() []*eventstore.FieldOperation { diff --git a/internal/repository/project/grant_member.go b/internal/repository/project/grant_member.go index 0e7947c86..3d19626ec 100644 --- a/internal/repository/project/grant_member.go +++ b/internal/repository/project/grant_member.go @@ -37,14 +37,34 @@ type GrantMemberAddedEvent struct { Roles []string `json:"roles"` UserID string `json:"userId"` GrantID string `json:"grantId"` + // userResourceOwner and grantedOrg are not persisted in the event payload. + userResourceOwner string + grantedOrg string } func (e *GrantMemberAddedEvent) Payload() interface{} { return e } +func grantMemberOwnerTags(agg *eventstore.Aggregate, userID, userResourceOwner, grantID, grantedOrg string) []string { + return []string{ + eventstore.OwnerTag(eventstore.UniqueConstraintOwnerOrg, agg.ResourceOwner), + eventstore.OwnerTag(eventstore.UniqueConstraintOwnerOrg, userResourceOwner), + eventstore.OwnerTag(eventstore.UniqueConstraintOwnerOrg, grantedOrg), + eventstore.OwnerTag(eventstore.UniqueConstraintOwnerUser, userID), + eventstore.OwnerTag(eventstore.UniqueConstraintOwnerProject, agg.ID), + eventstore.OwnerTag(eventstore.UniqueConstraintOwnerGrant, grantID), + } +} + func (e *GrantMemberAddedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { - return []*eventstore.UniqueConstraint{NewAddProjectGrantMemberUniqueConstraint(e.Aggregate().ID, e.UserID, e.GrantID)} + return []*eventstore.UniqueConstraint{NewAddProjectGrantMemberUniqueConstraint(e.Aggregate().ID, e.UserID, e.GrantID).WithOwners(grantMemberOwnerTags(e.Aggregate(), e.UserID, e.userResourceOwner, e.GrantID, e.grantedOrg)...)} +} + +func (e *GrantMemberAddedEvent) WithOwnerOrgs(userResourceOwner, grantedOrg string) *GrantMemberAddedEvent { + e.userResourceOwner = userResourceOwner + e.grantedOrg = grantedOrg + return e } func NewProjectGrantMemberAddedEvent( diff --git a/internal/repository/project/member.go b/internal/repository/project/member.go index 6fb3ceddf..7a50b81ec 100644 --- a/internal/repository/project/member.go +++ b/internal/repository/project/member.go @@ -45,6 +45,11 @@ func NewProjectMemberAddedEvent( } } +func (e *MemberAddedEvent) WithUserResourceOwner(userResourceOwner string) *MemberAddedEvent { + e.MemberAddedEvent.WithUserResourceOwner(userResourceOwner) + return e +} + func MemberAddedEventMapper(event eventstore.Event) (eventstore.Event, error) { e, err := member.MemberAddedEventMapper(event) if err != nil { diff --git a/internal/repository/project/project.go b/internal/repository/project/project.go index ba6fcd9c0..848438e00 100644 --- a/internal/repository/project/project.go +++ b/internal/repository/project/project.go @@ -53,8 +53,24 @@ func (e *ProjectAddedEvent) Payload() interface{} { return e } +func projectOwnerTags(agg *eventstore.Aggregate) []string { + return []string{ + eventstore.OwnerTag(eventstore.UniqueConstraintOwnerOrg, agg.ResourceOwner), + eventstore.OwnerTag(eventstore.UniqueConstraintOwnerProject, agg.ID), + } +} + +func grantOwnerTags(agg *eventstore.Aggregate, grantID, grantedOrgID string) []string { + return []string{ + eventstore.OwnerTag(eventstore.UniqueConstraintOwnerOrg, agg.ResourceOwner), + eventstore.OwnerTag(eventstore.UniqueConstraintOwnerOrg, grantedOrgID), + eventstore.OwnerTag(eventstore.UniqueConstraintOwnerProject, agg.ID), + eventstore.OwnerTag(eventstore.UniqueConstraintOwnerGrant, grantID), + } +} + func (e *ProjectAddedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { - return []*eventstore.UniqueConstraint{NewAddProjectNameUniqueConstraint(e.Name, e.Aggregate().ResourceOwner)} + return []*eventstore.UniqueConstraint{NewAddProjectNameUniqueConstraint(e.Name, e.Aggregate().ResourceOwner).WithOwners(projectOwnerTags(e.Aggregate())...)} } func (e *ProjectAddedEvent) Fields() []*eventstore.FieldOperation { @@ -151,7 +167,7 @@ func (e *ProjectChangeEvent) UniqueConstraints() []*eventstore.UniqueConstraint if e.Name != nil { return []*eventstore.UniqueConstraint{ NewRemoveProjectNameUniqueConstraint(e.oldName, e.Aggregate().ResourceOwner), - NewAddProjectNameUniqueConstraint(*e.Name, e.Aggregate().ResourceOwner), + NewAddProjectNameUniqueConstraint(*e.Name, e.Aggregate().ResourceOwner).WithOwners(projectOwnerTags(e.Aggregate())...), } } return nil @@ -358,12 +374,11 @@ func (e *ProjectRemovedEvent) Payload() interface{} { } func (e *ProjectRemovedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { - constraints := []*eventstore.UniqueConstraint{NewRemoveProjectNameUniqueConstraint(e.Name, e.Aggregate().ResourceOwner)} - if e.entityIDUniqueContraints != nil { - for _, constraint := range e.entityIDUniqueContraints { - constraints = append(constraints, constraint) - } + constraints := []*eventstore.UniqueConstraint{ + NewRemoveProjectNameUniqueConstraint(e.Name, e.Aggregate().ResourceOwner), + eventstore.NewRemoveUniqueConstraintsByOwner(eventstore.UniqueConstraintOwnerProject, e.Aggregate().ID), } + constraints = append(constraints, e.entityIDUniqueContraints...) return constraints } diff --git a/internal/repository/project/role.go b/internal/repository/project/role.go index 92d5f5198..3b8262669 100644 --- a/internal/repository/project/role.go +++ b/internal/repository/project/role.go @@ -48,7 +48,7 @@ func (e *RoleAddedEvent) Payload() interface{} { } func (e *RoleAddedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { - return []*eventstore.UniqueConstraint{NewAddProjectRoleUniqueConstraint(e.Key, e.Aggregate().ID)} + return []*eventstore.UniqueConstraint{NewAddProjectRoleUniqueConstraint(e.Key, e.Aggregate().ID).WithOwners(projectOwnerTags(e.Aggregate())...)} } func (e *RoleAddedEvent) Fields() []*eventstore.FieldOperation { diff --git a/internal/repository/project/saml_config.go b/internal/repository/project/saml_config.go index ddcb9c0ea..06f621ab5 100644 --- a/internal/repository/project/saml_config.go +++ b/internal/repository/project/saml_config.go @@ -43,7 +43,7 @@ func NewRemoveSAMLConfigEntityIDUniqueConstraint(entityID string) *eventstore.Un } func (e *SAMLConfigAddedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { - return []*eventstore.UniqueConstraint{NewAddSAMLConfigEntityIDUniqueConstraint(e.EntityID)} + return []*eventstore.UniqueConstraint{NewAddSAMLConfigEntityIDUniqueConstraint(e.EntityID).WithOwners(projectOwnerTags(e.Aggregate())...)} } func NewSAMLConfigAddedEvent( @@ -104,7 +104,7 @@ func (e *SAMLConfigChangedEvent) UniqueConstraints() []*eventstore.UniqueConstra if e.EntityID != "" { return []*eventstore.UniqueConstraint{ NewRemoveSAMLConfigEntityIDUniqueConstraint(e.oldEntityID), - NewAddSAMLConfigEntityIDUniqueConstraint(e.EntityID), + NewAddSAMLConfigEntityIDUniqueConstraint(e.EntityID).WithOwners(projectOwnerTags(e.Aggregate())...), } } return nil diff --git a/internal/repository/unique_constraint_owners_test.go b/internal/repository/unique_constraint_owners_test.go new file mode 100644 index 000000000..5d40d145d --- /dev/null +++ b/internal/repository/unique_constraint_owners_test.go @@ -0,0 +1,234 @@ +package repository_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/action" + "github.com/zitadel/zitadel/internal/repository/group" + "github.com/zitadel/zitadel/internal/repository/idpconfig" + "github.com/zitadel/zitadel/internal/repository/instance" + "github.com/zitadel/zitadel/internal/repository/member" + "github.com/zitadel/zitadel/internal/repository/org" + "github.com/zitadel/zitadel/internal/repository/policy" + "github.com/zitadel/zitadel/internal/repository/project" + "github.com/zitadel/zitadel/internal/repository/target" + "github.com/zitadel/zitadel/internal/repository/user" + "github.com/zitadel/zitadel/internal/repository/usergrant" +) + +func TestUniqueConstraintOwners(t *testing.T) { + ctx := t.Context() + + tests := []struct { + name string + cmd eventstore.Command + want [][]string + }{ + { + name: "username", + cmd: &user.HumanAddedEvent{ + BaseEvent: eventstore.BaseEvent{Agg: agg(user.AggregateType, "user-1", "org-1")}, + UserName: "alice", + }, + want: [][]string{{"org:org-1", "user:user-1"}}, + }, + { + name: "external idp link", + cmd: user.NewUserIDPLinkAddedEvent(ctx, agg(user.AggregateType, "user-1", "org-1"), "idp-1", "display", "ext-1"), + want: [][]string{{"org:org-1", "idp:idp-1", "user:user-1"}}, + }, + { + name: "org name", + cmd: org.NewOrgAddedEvent(ctx, agg(org.AggregateType, "org-1", "org-1"), "acme"), + want: [][]string{{"org:org-1"}}, + }, + { + name: "org domain", + cmd: org.NewDomainVerifiedEvent(ctx, agg(org.AggregateType, "org-1", "org-1"), "acme.example"), + want: [][]string{{"org:org-1"}}, + }, + { + name: "app name", + cmd: project.NewApplicationAddedEvent(ctx, agg(project.AggregateType, "project-1", "org-1"), "app-1", "console"), + want: [][]string{{"org:org-1", "project:project-1"}}, + }, + { + name: "project role", + cmd: project.NewRoleAddedEvent(ctx, agg(project.AggregateType, "project-1", "org-1"), "role.key", "Role", "group"), + want: [][]string{{"org:org-1", "project:project-1"}}, + }, + { + name: "saml entity id", + cmd: project.NewSAMLConfigAddedEvent(ctx, agg(project.AggregateType, "project-1", "org-1"), "app-1", "https://sp.example", nil, "", 0, ""), + want: [][]string{{"org:org-1", "project:project-1"}}, + }, + { + name: "project name", + cmd: &project.ProjectAddedEvent{ + BaseEvent: eventstore.BaseEvent{Agg: agg(project.AggregateType, "project-1", "org-1")}, + Name: "docs", + }, + want: [][]string{{"org:org-1", "project:project-1"}}, + }, + { + name: "project grant two orgs", + cmd: project.NewGrantAddedEvent(ctx, agg(project.AggregateType, "project-1", "granting-org"), "grant-1", "granted-org", nil), + want: [][]string{{"org:granting-org", "org:granted-org", "project:project-1", "grant:grant-1"}}, + }, + { + name: "project grant member", + cmd: project.NewProjectGrantMemberAddedEvent(ctx, agg(project.AggregateType, "project-1", "org-1"), "user-1", "grant-1", "ROLE"). + WithOwnerOrgs("user-org", "granted-org"), + want: [][]string{{"org:org-1", "org:user-org", "org:granted-org", "user:user-1", "project:project-1", "grant:grant-1"}}, + }, + { + name: "user grant", + cmd: usergrant.NewUserGrantAddedEvent(ctx, agg(user.AggregateType, "grant-row", "org-1"), "user-1", "project-1", "grant-1", nil). + WithOwnerOrgs("user-org", "project-org", "granted-org"), + want: [][]string{{"org:org-1", "org:user-org", "org:project-org", "org:granted-org", "user:user-1", "project:project-1", "grant:grant-1"}}, + }, + { + name: "org member", + cmd: member.NewMemberAddedEvent( + &eventstore.BaseEvent{Agg: agg(org.AggregateType, "org-1", "org-1")}, + "user-1", + ).WithUserResourceOwner("org-1"), + want: [][]string{{"user:user-1", "org:org-1"}}, + }, + { + name: "org member cross-org user", + cmd: member.NewMemberAddedEvent( + &eventstore.BaseEvent{Agg: agg(org.AggregateType, "org-1", "org-1")}, + "user-1", + ).WithUserResourceOwner("user-org"), + want: [][]string{{"user:user-1", "org:user-org", "org:org-1"}}, + }, + { + name: "project member", + cmd: member.NewMemberAddedEvent( + &eventstore.BaseEvent{Agg: agg(project.AggregateType, "project-1", "org-1")}, + "user-1", + ).WithUserResourceOwner("org-1"), + want: [][]string{{"user:user-1", "org:org-1", "project:project-1"}}, + }, + { + name: "project member cross-org user", + cmd: member.NewMemberAddedEvent( + &eventstore.BaseEvent{Agg: agg(project.AggregateType, "project-1", "org-1")}, + "user-1", + ).WithUserResourceOwner("user-org"), + want: [][]string{{"user:user-1", "org:user-org", "org:org-1", "project:project-1"}}, + }, + { + name: "instance member", + cmd: member.NewMemberAddedEvent( + &eventstore.BaseEvent{Agg: agg(instance.AggregateType, "instance-1", "instance-1")}, + "user-1", + ).WithUserResourceOwner("user-org"), + want: [][]string{{"user:user-1", "org:user-org"}}, + }, + { + name: "group name", + cmd: group.NewGroupAddedEvent(ctx, agg(group.AggregateType, "group-1", "org-1"), "engineering", ""), + want: [][]string{{"org:org-1"}}, + }, + { + name: "action name", + cmd: action.NewAddedEvent(ctx, agg(action.AggregateType, "action-1", "org-1"), "notify", "", 0, false), + want: [][]string{{"org:org-1"}}, + }, + { + name: "idp config name", + cmd: idpconfig.NewIDPConfigAddedEvent(&eventstore.BaseEvent{Agg: agg(org.AggregateType, "org-1", "org-1")}, "idp-1", "Google", 0, 0, false), + want: [][]string{{"org:org-1", "idp:idp-1"}}, + }, + { + name: "mail text org", + cmd: policy.NewMailTextAddedEvent(&eventstore.BaseEvent{Agg: agg(org.AggregateType, "org-1", "org-1")}, "InitCode", "en", "", "", "", "", "", ""), + want: [][]string{{"org:org-1"}}, + }, + { + name: "mail text instance empty owners", + cmd: policy.NewMailTextAddedEvent(&eventstore.BaseEvent{Agg: agg(instance.AggregateType, "instance-1", "instance-1")}, "InitCode", "en", "", "", "", "", "", ""), + want: [][]string{nil}, + }, + { + name: "instance domain global empty owners", + cmd: instance.NewDomainAddedEvent(ctx, agg(instance.AggregateType, "instance-1", "instance-1"), "acme.example", true), + want: [][]string{nil}, + }, + { + name: "target instance only empty owners", + cmd: target.NewAddedEvent(ctx, agg(target.AggregateType, "target-1", "instance-1"), "hook", 0, "", 0, false, nil, 0), + want: [][]string{nil}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + constraints := tt.cmd.UniqueConstraints() + require.Len(t, constraints, len(tt.want)) + for i, constraint := range constraints { + assert.Equal(t, eventstore.UniqueConstraintAdd, constraint.Action) + assert.ElementsMatch(t, tt.want[i], constraint.Owners) + } + }) + } +} + +func TestBulkRemoveAlsoEmitsRemoveByOwner(t *testing.T) { + ctx := t.Context() + orgAgg := agg(org.AggregateType, "org-1", "org-1") + orgConstraints := org.NewOrgRemovedEvent(ctx, orgAgg, "acme", nil, false, nil, nil, nil).UniqueConstraints() + require.GreaterOrEqual(t, len(orgConstraints), 2) + assert.Equal(t, eventstore.UniqueConstraintRemoveByOwner, orgConstraints[1].Action) + assert.Equal(t, []string{"org:org-1"}, orgConstraints[1].Owners) + + userAgg := agg(user.AggregateType, "user-1", "org-1") + userConstraints := user.NewUserRemovedEvent(ctx, userAgg, "alice", nil, false).UniqueConstraints() + last := userConstraints[len(userConstraints)-1] + assert.Equal(t, eventstore.UniqueConstraintRemoveByOwner, last.Action) + assert.Equal(t, []string{"user:user-1"}, last.Owners) + + idpConstraints := idpconfig.NewIDPConfigRemovedEvent(&eventstore.BaseEvent{Agg: orgAgg}, "idp-1", "Google").UniqueConstraints() + assert.Equal(t, eventstore.UniqueConstraintRemoveByOwner, idpConstraints[1].Action) + assert.Equal(t, []string{"idp:idp-1"}, idpConstraints[1].Owners) + + projectAgg := agg(project.AggregateType, "project-1", "org-1") + projectConstraints := project.NewProjectRemovedEvent(ctx, projectAgg, "docs", nil).UniqueConstraints() + assert.Equal(t, eventstore.UniqueConstraintRemoveByOwner, projectConstraints[1].Action) + assert.Equal(t, []string{"project:project-1"}, projectConstraints[1].Owners) + + grantConstraints := project.NewGrantRemovedEvent(ctx, projectAgg, "grant-1", "granted-org").UniqueConstraints() + require.Len(t, grantConstraints, 2) + assert.Equal(t, eventstore.UniqueConstraintRemove, grantConstraints[0].Action) + assert.Equal(t, eventstore.UniqueConstraintRemoveByOwner, grantConstraints[1].Action) + assert.Equal(t, []string{"grant:grant-1"}, grantConstraints[1].Owners) +} + +func TestUsernameScopeRewriteKeepsUserTag(t *testing.T) { + constraints := user.NewUsernameUniqueConstraints( + []user.UsernameChange{{Username: "alice", UserID: "user-1"}}, + "org-1", + true, + false, + ) + require.Len(t, constraints, 2) + assert.Equal(t, eventstore.UniqueConstraintRemove, constraints[0].Action) + assert.Equal(t, eventstore.UniqueConstraintAdd, constraints[1].Action) + assert.ElementsMatch(t, []string{"org:org-1", "user:user-1"}, constraints[1].Owners) +} + +func agg(typ eventstore.AggregateType, id, resourceOwner string) *eventstore.Aggregate { + return &eventstore.Aggregate{ + ID: id, + Type: typ, + ResourceOwner: resourceOwner, + InstanceID: "instance-1", + Version: "v1", + } +} diff --git a/internal/repository/user/human.go b/internal/repository/user/human.go index e0d6ad647..397439f57 100644 --- a/internal/repository/user/human.go +++ b/internal/repository/user/human.go @@ -65,7 +65,7 @@ func (e *HumanAddedEvent) Payload() interface{} { } func (e *HumanAddedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { - return []*eventstore.UniqueConstraint{NewAddUsernameUniqueConstraint(e.UserName, e.Aggregate().ResourceOwner, e.orgScopedUsername)} + return []*eventstore.UniqueConstraint{NewAddUsernameUniqueConstraint(e.UserName, e.Aggregate().ResourceOwner, e.orgScopedUsername).WithOwners(usernameOwnerTags(e.Aggregate())...)} } func (e *HumanAddedEvent) AddAddressData( @@ -176,7 +176,7 @@ func (e *HumanRegisteredEvent) Payload() interface{} { } func (e *HumanRegisteredEvent) UniqueConstraints() []*eventstore.UniqueConstraint { - return []*eventstore.UniqueConstraint{NewAddUsernameUniqueConstraint(e.UserName, e.Aggregate().ResourceOwner, e.orgScopedUsername)} + return []*eventstore.UniqueConstraint{NewAddUsernameUniqueConstraint(e.UserName, e.Aggregate().ResourceOwner, e.orgScopedUsername).WithOwners(usernameOwnerTags(e.Aggregate())...)} } func (e *HumanRegisteredEvent) AddAddressData( diff --git a/internal/repository/user/human_external_idp.go b/internal/repository/user/human_external_idp.go index b5665ea4d..7e8b1a110 100644 --- a/internal/repository/user/human_external_idp.go +++ b/internal/repository/user/human_external_idp.go @@ -47,7 +47,7 @@ func (e *UserIDPLinkAddedEvent) Payload() interface{} { } func (e *UserIDPLinkAddedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { - return []*eventstore.UniqueConstraint{NewAddUserIDPLinkUniqueConstraint(e.IDPConfigID, e.ExternalUserID)} + return []*eventstore.UniqueConstraint{NewAddUserIDPLinkUniqueConstraint(e.IDPConfigID, e.ExternalUserID).WithOwners(idpLinkOwnerTags(e.Aggregate(), e.IDPConfigID)...)} } func NewUserIDPLinkAddedEvent( diff --git a/internal/repository/user/machine.go b/internal/repository/user/machine.go index 794941bf3..96be6df6d 100644 --- a/internal/repository/user/machine.go +++ b/internal/repository/user/machine.go @@ -32,7 +32,7 @@ func (e *MachineAddedEvent) Payload() interface{} { } func (e *MachineAddedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { - return []*eventstore.UniqueConstraint{NewAddUsernameUniqueConstraint(e.UserName, e.Aggregate().ResourceOwner, e.orgScopedUsername)} + return []*eventstore.UniqueConstraint{NewAddUsernameUniqueConstraint(e.UserName, e.Aggregate().ResourceOwner, e.orgScopedUsername).WithOwners(usernameOwnerTags(e.Aggregate())...)} } func NewMachineAddedEvent( diff --git a/internal/repository/user/user.go b/internal/repository/user/user.go index 68d74cb0a..b7629fe2a 100644 --- a/internal/repository/user/user.go +++ b/internal/repository/user/user.go @@ -47,18 +47,42 @@ func NewRemoveUsernameUniqueConstraint(userName, resourceOwner string, orgScoped uniqueUserName) } -func NewUsernameUniqueConstraints(usernameChanges []string, resourceOwner string, orgScopedUsername, oldOrgScopedUsername bool) []*eventstore.UniqueConstraint { +// UsernameChange is a username rewrite keyed by the owning user so owner tags stay intact. +type UsernameChange struct { + Username string + UserID string +} + +func NewUsernameUniqueConstraints(usernameChanges []UsernameChange, resourceOwner string, orgScopedUsername, oldOrgScopedUsername bool) []*eventstore.UniqueConstraint { if len(usernameChanges) == 0 || oldOrgScopedUsername == orgScopedUsername { return []*eventstore.UniqueConstraint{} } changes := make([]*eventstore.UniqueConstraint, len(usernameChanges)*2) - for i, username := range usernameChanges { - changes[i*2] = NewRemoveUsernameUniqueConstraint(username, resourceOwner, oldOrgScopedUsername) - changes[i*2+1] = NewAddUsernameUniqueConstraint(username, resourceOwner, orgScopedUsername) + for i, change := range usernameChanges { + changes[i*2] = NewRemoveUsernameUniqueConstraint(change.Username, resourceOwner, oldOrgScopedUsername) + changes[i*2+1] = NewAddUsernameUniqueConstraint(change.Username, resourceOwner, orgScopedUsername).WithOwners( + eventstore.OwnerTag(eventstore.UniqueConstraintOwnerOrg, resourceOwner), + eventstore.OwnerTag(eventstore.UniqueConstraintOwnerUser, change.UserID), + ) } return changes } +func usernameOwnerTags(agg *eventstore.Aggregate) []string { + return []string{ + eventstore.OwnerTag(eventstore.UniqueConstraintOwnerOrg, agg.ResourceOwner), + eventstore.OwnerTag(eventstore.UniqueConstraintOwnerUser, agg.ID), + } +} + +func idpLinkOwnerTags(agg *eventstore.Aggregate, idpConfigID string) []string { + return []string{ + eventstore.OwnerTag(eventstore.UniqueConstraintOwnerOrg, agg.ResourceOwner), + eventstore.OwnerTag(eventstore.UniqueConstraintOwnerIDP, idpConfigID), + eventstore.OwnerTag(eventstore.UniqueConstraintOwnerUser, agg.ID), + } +} + type UserLockedEvent struct { eventstore.BaseEvent `json:"-"` } @@ -184,13 +208,14 @@ func (e *UserRemovedEvent) Payload() interface{} { } func (e *UserRemovedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { - events := make([]*eventstore.UniqueConstraint, 0) + events := make([]*eventstore.UniqueConstraint, 0, 2+len(e.externalIDPs)) if e.userName != "" { events = append(events, NewRemoveUsernameUniqueConstraint(e.userName, e.Aggregate().ResourceOwner, e.orgScopedUsername)) } for _, idp := range e.externalIDPs { events = append(events, NewRemoveUserIDPLinkUniqueConstraint(idp.IDPConfigID, idp.ExternalUserID)) } + events = append(events, eventstore.NewRemoveUniqueConstraintsByOwner(eventstore.UniqueConstraintOwnerUser, e.Aggregate().ID)) return events } @@ -385,7 +410,7 @@ func (e *DomainClaimedEvent) Payload() interface{} { func (e *DomainClaimedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { return []*eventstore.UniqueConstraint{ NewRemoveUsernameUniqueConstraint(e.oldUserName, e.Aggregate().ResourceOwner, e.orgScopedUsername), - NewAddUsernameUniqueConstraint(e.UserName, e.Aggregate().ResourceOwner, e.orgScopedUsername), + NewAddUsernameUniqueConstraint(e.UserName, e.Aggregate().ResourceOwner, e.orgScopedUsername).WithOwners(usernameOwnerTags(e.Aggregate())...), } } @@ -486,7 +511,7 @@ func (e *UsernameChangedEvent) UniqueConstraints() []*eventstore.UniqueConstrain return []*eventstore.UniqueConstraint{ NewRemoveUsernameUniqueConstraint(e.oldUserName, e.Aggregate().ResourceOwner, oldSetting), - NewAddUsernameUniqueConstraint(e.UserName, e.Aggregate().ResourceOwner, newSetting), + NewAddUsernameUniqueConstraint(e.UserName, e.Aggregate().ResourceOwner, newSetting).WithOwners(usernameOwnerTags(e.Aggregate())...), } } diff --git a/internal/repository/usergrant/user_grant.go b/internal/repository/usergrant/user_grant.go index ee5c8fb8b..223f59a84 100644 --- a/internal/repository/usergrant/user_grant.go +++ b/internal/repository/usergrant/user_grant.go @@ -40,14 +40,37 @@ type UserGrantAddedEvent struct { ProjectID string `json:"projectId,omitempty"` ProjectGrantID string `json:"grantId,omitempty"` RoleKeys []string `json:"roleKeys,omitempty"` + // owner orgs are not persisted in the event payload. + userResourceOwner string + projectResourceOwner string + grantedOrg string } func (e *UserGrantAddedEvent) Payload() interface{} { return e } +func userGrantOwnerTags(agg *eventstore.Aggregate, userID, userResourceOwner, projectID, projectResourceOwner, grantID, grantedOrg string) []string { + return []string{ + eventstore.OwnerTag(eventstore.UniqueConstraintOwnerOrg, agg.ResourceOwner), + eventstore.OwnerTag(eventstore.UniqueConstraintOwnerOrg, userResourceOwner), + eventstore.OwnerTag(eventstore.UniqueConstraintOwnerOrg, projectResourceOwner), + eventstore.OwnerTag(eventstore.UniqueConstraintOwnerOrg, grantedOrg), + eventstore.OwnerTag(eventstore.UniqueConstraintOwnerUser, userID), + eventstore.OwnerTag(eventstore.UniqueConstraintOwnerProject, projectID), + eventstore.OwnerTag(eventstore.UniqueConstraintOwnerGrant, grantID), + } +} + func (e *UserGrantAddedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { - return []*eventstore.UniqueConstraint{NewAddUserGrantUniqueConstraint(e.Aggregate().ResourceOwner, e.UserID, e.ProjectID, e.ProjectGrantID)} + return []*eventstore.UniqueConstraint{NewAddUserGrantUniqueConstraint(e.Aggregate().ResourceOwner, e.UserID, e.ProjectID, e.ProjectGrantID).WithOwners(userGrantOwnerTags(e.Aggregate(), e.UserID, e.userResourceOwner, e.ProjectID, e.projectResourceOwner, e.ProjectGrantID, e.grantedOrg)...)} +} + +func (e *UserGrantAddedEvent) WithOwnerOrgs(userResourceOwner, projectResourceOwner, grantedOrg string) *UserGrantAddedEvent { + e.userResourceOwner = userResourceOwner + e.projectResourceOwner = projectResourceOwner + e.grantedOrg = grantedOrg + return e } func NewUserGrantAddedEvent( diff --git a/internal/v2/eventstore/postgres/push.go b/internal/v2/eventstore/postgres/push.go index abf1b6026..cb2027d53 100644 --- a/internal/v2/eventstore/postgres/push.go +++ b/internal/v2/eventstore/postgres/push.go @@ -216,9 +216,15 @@ func uniqueConstraints(ctx context.Context, tx *sql.Tx, commands []*command) (er } switch constraint.Action { case eventstore.UniqueConstraintAdd: - stmt.WriteString(`INSERT INTO eventstore.unique_constraints (instance_id, unique_type, unique_field) VALUES (`) - stmt.WriteArgs(instance, constraint.UniqueType, constraint.UniqueField) - stmt.WriteRune(')') + stmt.WriteString(`INSERT INTO eventstore.unique_constraints (instance_id, unique_type, unique_field, owners) VALUES (`) + stmt.WriteArg(instance) + stmt.WriteString(`, `) + stmt.WriteArg(constraint.UniqueType) + stmt.WriteString(`, `) + stmt.WriteArg(constraint.UniqueField) + stmt.WriteString(`, `) + stmt.WriteArg(constraint.Owners) + stmt.WriteString(`)`) case eventstore.UniqueConstraintInstanceRemove: stmt.WriteString(`DELETE FROM eventstore.unique_constraints WHERE instance_id = `) stmt.WriteArgs(instance) @@ -230,6 +236,15 @@ func uniqueConstraints(ctx context.Context, tx *sql.Tx, commands []*command) (er constraint.UniqueType, constraint.UniqueField, ) + case eventstore.UniqueConstraintRemoveByOwner: + if len(constraint.Owners) == 0 { + continue + } + stmt.WriteString(`DELETE FROM eventstore.unique_constraints WHERE instance_id = `) + stmt.WriteArgs(instance) + stmt.WriteString(` AND owners @> ARRAY[`) + stmt.WriteArgs(constraint.Owners[0]) + stmt.WriteString(`]::text[]`) } _, err := tx.ExecContext(ctx, stmt.String(), stmt.Args()...) if err != nil { diff --git a/internal/v2/eventstore/postgres/push_test.go b/internal/v2/eventstore/postgres/push_test.go index afd5fe8b8..f5960d4e9 100644 --- a/internal/v2/eventstore/postgres/push_test.go +++ b/internal/v2/eventstore/postgres/push_test.go @@ -66,8 +66,8 @@ func Test_uniqueConstraints(t *testing.T) { }, expectations: []mock.Expectation{ mock.ExpectExec( - "INSERT INTO eventstore.unique_constraints (instance_id, unique_type, unique_field) VALUES ($1, $2, $3)", - mock.WithExecArgs("instance", "test", "id"), + "INSERT INTO eventstore.unique_constraints (instance_id, unique_type, unique_field, owners) VALUES ($1, $2, $3, $4)", + mock.WithExecArgs("instance", "test", "id", []string{}), mock.WithExecRowsAffected(1), ), }, @@ -94,8 +94,8 @@ func Test_uniqueConstraints(t *testing.T) { }, expectations: []mock.Expectation{ mock.ExpectExec( - "INSERT INTO eventstore.unique_constraints (instance_id, unique_type, unique_field) VALUES ($1, $2, $3)", - mock.WithExecArgs("", "test", "id"), + "INSERT INTO eventstore.unique_constraints (instance_id, unique_type, unique_field, owners) VALUES ($1, $2, $3, $4)", + mock.WithExecArgs("", "test", "id", []string{}), mock.WithExecRowsAffected(1), ), }, @@ -123,13 +123,13 @@ func Test_uniqueConstraints(t *testing.T) { }, expectations: []mock.Expectation{ mock.ExpectExec( - "INSERT INTO eventstore.unique_constraints (instance_id, unique_type, unique_field) VALUES ($1, $2, $3)", - mock.WithExecArgs("instance", "test", "id"), + "INSERT INTO eventstore.unique_constraints (instance_id, unique_type, unique_field, owners) VALUES ($1, $2, $3, $4)", + mock.WithExecArgs("instance", "test", "id", []string{}), mock.WithExecRowsAffected(1), ), mock.ExpectExec( - "INSERT INTO eventstore.unique_constraints (instance_id, unique_type, unique_field) VALUES ($1, $2, $3)", - mock.WithExecArgs("instance", "test", "id2"), + "INSERT INTO eventstore.unique_constraints (instance_id, unique_type, unique_field, owners) VALUES ($1, $2, $3, $4)", + mock.WithExecArgs("instance", "test", "id2", []string{}), mock.WithExecRowsAffected(1), ), }, @@ -169,13 +169,13 @@ func Test_uniqueConstraints(t *testing.T) { }, expectations: []mock.Expectation{ mock.ExpectExec( - "INSERT INTO eventstore.unique_constraints (instance_id, unique_type, unique_field) VALUES ($1, $2, $3)", - mock.WithExecArgs("instance", "test", "id"), + "INSERT INTO eventstore.unique_constraints (instance_id, unique_type, unique_field, owners) VALUES ($1, $2, $3, $4)", + mock.WithExecArgs("instance", "test", "id", []string{}), mock.WithExecRowsAffected(1), ), mock.ExpectExec( - "INSERT INTO eventstore.unique_constraints (instance_id, unique_type, unique_field) VALUES ($1, $2, $3)", - mock.WithExecArgs("instance", "test", "id2"), + "INSERT INTO eventstore.unique_constraints (instance_id, unique_type, unique_field, owners) VALUES ($1, $2, $3, $4)", + mock.WithExecArgs("instance", "test", "id2", []string{}), mock.WithExecRowsAffected(1), ), }, @@ -392,6 +392,34 @@ func Test_uniqueConstraints(t *testing.T) { }, assertErr: expectNoErr, }, + { + name: "remove by owner 1 command", + args: args{ + commands: []*command{ + { + intent: &intent{ + PushAggregate: eventstore.NewPushIntent( + "instance", + eventstore.AppendAggregate("", "", ""), + ).Aggregates()[0], + }, + Command: &eventstore.Command{ + UniqueConstraints: []*eventstore.UniqueConstraint{ + eventstore.NewRemoveUniqueConstraintsByOwner(eventstore.UniqueConstraintOwnerOrg, "org-1"), + }, + }, + }, + }, + expectations: []mock.Expectation{ + mock.ExpectExec( + `DELETE FROM eventstore.unique_constraints WHERE instance_id = $1 AND owners @> ARRAY[$2]::text[]`, + mock.WithExecArgs("instance", "org:org-1"), + mock.WithExecRowsAffected(3), + ), + }, + }, + assertErr: expectNoErr, + }, { name: "exec fails no error specified", args: args{ @@ -412,8 +440,8 @@ func Test_uniqueConstraints(t *testing.T) { }, expectations: []mock.Expectation{ mock.ExpectExec( - "INSERT INTO eventstore.unique_constraints (instance_id, unique_type, unique_field) VALUES ($1, $2, $3)", - mock.WithExecArgs("instance", "test", "id"), + "INSERT INTO eventstore.unique_constraints (instance_id, unique_type, unique_field, owners) VALUES ($1, $2, $3, $4)", + mock.WithExecArgs("instance", "test", "id", []string{}), mock.WithExecErr(execErr), ), }, @@ -446,8 +474,8 @@ func Test_uniqueConstraints(t *testing.T) { }, expectations: []mock.Expectation{ mock.ExpectExec( - "INSERT INTO eventstore.unique_constraints (instance_id, unique_type, unique_field) VALUES ($1, $2, $3)", - mock.WithExecArgs("instance", "test", "id"), + "INSERT INTO eventstore.unique_constraints (instance_id, unique_type, unique_field, owners) VALUES ($1, $2, $3, $4)", + mock.WithExecArgs("instance", "test", "id", []string{}), mock.WithExecErr(execErr), ), }, diff --git a/internal/v2/eventstore/unique_constraint.go b/internal/v2/eventstore/unique_constraint.go index 4486e19e5..96f7cdb49 100644 --- a/internal/v2/eventstore/unique_constraint.go +++ b/internal/v2/eventstore/unique_constraint.go @@ -11,6 +11,8 @@ type UniqueConstraint struct { ErrorMessage string // IsGlobal defines if the unique constraint is globally unique or just within a single instance IsGlobal bool + // Owners is a bag of kind:id tags used for bulk lifecycle deletes + Owners []string } type UniqueConstraintAction int8 @@ -19,14 +21,47 @@ const ( UniqueConstraintAdd UniqueConstraintAction = iota UniqueConstraintRemove UniqueConstraintInstanceRemove + UniqueConstraintRemoveByOwner uniqueConstraintActionCount ) +const ( + UniqueConstraintOwnerOrg = "org" + UniqueConstraintOwnerUser = "user" + UniqueConstraintOwnerIDP = "idp" + UniqueConstraintOwnerProject = "project" + UniqueConstraintOwnerGrant = "grant" +) + func (f UniqueConstraintAction) Valid() bool { return f >= 0 && f < uniqueConstraintActionCount } +func OwnerTag(kind, id string) string { + if kind == "" || id == "" { + return "" + } + return kind + ":" + id +} + +func (u *UniqueConstraint) WithOwners(tags ...string) *UniqueConstraint { + owners := make([]string, 0, len(tags)) + seen := make(map[string]struct{}, len(tags)) + for _, tag := range tags { + if tag == "" { + continue + } + if _, ok := seen[tag]; ok { + continue + } + seen[tag] = struct{}{} + owners = append(owners, tag) + } + u.Owners = owners + return u +} + func NewAddEventUniqueConstraint( uniqueType, uniqueField, @@ -36,6 +71,7 @@ func NewAddEventUniqueConstraint( UniqueField: uniqueField, ErrorMessage: errMessage, Action: UniqueConstraintAdd, + Owners: []string{}, } } @@ -55,6 +91,16 @@ func NewRemoveInstanceUniqueConstraints() *UniqueConstraint { } } +func NewRemoveUniqueConstraintsByOwner(kind, id string) *UniqueConstraint { + constraint := &UniqueConstraint{ + Action: UniqueConstraintRemoveByOwner, + } + if tag := OwnerTag(kind, id); tag != "" { + constraint.Owners = []string{tag} + } + return constraint +} + func NewAddGlobalUniqueConstraint( uniqueType, uniqueField, @@ -65,6 +111,7 @@ func NewAddGlobalUniqueConstraint( ErrorMessage: errMessage, IsGlobal: true, Action: UniqueConstraintAdd, + Owners: []string{}, } } diff --git a/internal/v2/eventstore/unique_constraint_test.go b/internal/v2/eventstore/unique_constraint_test.go new file mode 100644 index 000000000..701f62766 --- /dev/null +++ b/internal/v2/eventstore/unique_constraint_test.go @@ -0,0 +1,32 @@ +package eventstore + +import "testing" + +func TestOwnerTag(t *testing.T) { + tests := []struct { + name string + kind string + id string + want string + }{ + {name: "org", kind: UniqueConstraintOwnerOrg, id: "123", want: "org:123"}, + {name: "empty id", kind: UniqueConstraintOwnerOrg, id: "", want: ""}, + {name: "empty kind", kind: "", id: "123", want: ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := OwnerTag(tt.kind, tt.id); got != tt.want { + t.Errorf("OwnerTag() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestUniqueConstraintAction_Valid(t *testing.T) { + if !UniqueConstraintRemoveByOwner.Valid() { + t.Error("UniqueConstraintRemoveByOwner should be valid") + } + if uniqueConstraintActionCount.Valid() { + t.Error("uniqueConstraintActionCount should be invalid") + } +}