fix(eventstore): add owners to unique constraints (#12740)

<!-- CURSOR_AGENT_PR_BODY_BEGIN -->
# 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:<id>`,
`user:<id>`, `idp:<id>`, `project:<id>`, `grant:<id>`).

- 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_resource_owner>`.
- 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:<id>`. Grant remove emits
`RemoveByOwner(grant)`.
- Username-scope rewrites keep `user:<id>` 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.

<!-- CURSOR_AGENT_PR_BODY_END -->

<div><a
href="https://cursor.com/agents/bc-44f9bc9b-9a93-4cd9-8dd1-663a7b9aad2a?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-web-light.png"><img
alt="Open in Web" width="114" height="28"
src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a
href="https://cursor.com/background-agent?bcId=bc-44f9bc9b-9a93-4cd9-8dd1-663a7b9aad2a&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img
alt="Open in Cursor" width="131" height="28"
src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div>

---------

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Silvan <[email protected]>
This commit is contained in:
Silvan
2026-09-18 15:08:42 +02:00
committed by GitHub
co-authored by Cursor Agent Silvan
parent 9e1b85fa91
commit 3ff4fbc7e4
95 changed files with 2411 additions and 207 deletions
@@ -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)
);
);
+2 -2
View File
@@ -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
+35
View File
@@ -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"
}
+1
View File
@@ -0,0 +1 @@
ALTER TABLE eventstore.unique_constraints ADD COLUMN IF NOT EXISTS owners TEXT[] NOT NULL DEFAULT '{}';
+1
View File
@@ -0,0 +1 @@
CREATE INDEX CONCURRENTLY IF NOT EXISTS unique_constraints_owners_gin ON eventstore.unique_constraints USING GIN (owners);
+28
View File
@@ -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())
}
+139
View File
@@ -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)
}
+8
View File
@@ -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));
+16
View File
@@ -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);
+8
View File
@@ -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));
+8
View File
@@ -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));
+9
View File
@@ -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));
+8
View File
@@ -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));
+8
View File
@@ -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));
+8
View File
@@ -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));
+9
View File
@@ -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));
+9
View File
@@ -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));
+11
View File
@@ -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));
+11
View File
@@ -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));
+9
View File
@@ -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));
+9
View File
@@ -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));
+9
View File
@@ -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));
+8
View File
@@ -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));
+8
View File
@@ -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));
+8
View File
@@ -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));
@@ -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));
+9
View File
@@ -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));
+213
View File
@@ -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"))
}
+2
View File
@@ -197,6 +197,8 @@ type Steps struct {
s75Apps7OIDCConfigsAddAppLinkConfig *Apps7OIDCConfigsAddAppLinkConfig
s76Users14LoginEqualityIndexes *Users14LoginEqualityIndexes
s77StampEventPositionAtInsert *StampEventPositionAtInsert
s78UniqueConstraintOwners *UniqueConstraintOwners
BackfillUniqueConstraintOwners *BackfillUniqueConstraintOwners
RelationalTables *TransactionalTables
}
@@ -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)
}
+8
View File
@@ -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 {
+7 -1
View File
@@ -184,4 +184,10 @@ FillFields:
RelationalTables:
# This is used for development DO NOT USE IN PRODUCTION
ShouldRecreateSchema: false # ZITADEL_RELATIONALTABLES_SHOULDRECREATESCHEMA
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
+6
View File
@@ -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)
}
+3 -2
View File
@@ -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
}
+8 -8
View File
@@ -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"),
),
),
),
+5 -5
View File
@@ -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,
+26 -23
View File
@@ -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) {
+3 -2
View File
@@ -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
}
+3 -3
View File
@@ -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(),
+27 -4
View File
@@ -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
+5 -4
View File
@@ -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
}
@@ -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 {
@@ -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,
),
+21 -12
View File
@@ -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) }()
+2 -2
View File
@@ -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
}
@@ -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(),
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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(),
+27
View File
@@ -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
@@ -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()
}
@@ -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,
}
}
+22 -3
View File
@@ -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) {
+44 -20
View File
@@ -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) {
+5
View File
@@ -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
+11 -11
View File
@@ -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{},
},
}
+48
View File
@@ -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 protected]",
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
+13 -1
View File
@@ -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)
@@ -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]--
}
}
+30 -8
View File
@@ -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
+70
View File
@@ -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{},
}
}
@@ -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")
}
}
}
+10 -2
View File
@@ -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
}
}
}
@@ -2,5 +2,6 @@ INSERT INTO eventstore.unique_constraints (
instance_id
, unique_type
, unique_field
, owners
) VALUES
%s
%s
+4
View File
@@ -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 {
+74
View File
@@ -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,
}
}
+6 -1
View File
@@ -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
}
}
+29
View File
@@ -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)
+2 -2
View File
@@ -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)),
}
}
+2 -2
View File
@@ -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 {
+13 -3
View File
@@ -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) {
+5
View File
@@ -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 {
+28 -1
View File
@@ -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 {
+1 -1
View File
@@ -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 {
+5
View File
@@ -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 {
+3 -2
View File
@@ -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))
@@ -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 {
+10 -1
View File
@@ -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(
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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())...),
}
}
+5 -2
View File
@@ -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 {
+21 -1
View File
@@ -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(
+5
View File
@@ -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 {
+22 -7
View File
@@ -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
}
+1 -1
View File
@@ -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 {
+2 -2
View File
@@ -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
@@ -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",
}
}
+2 -2
View File
@@ -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(
@@ -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(
+1 -1
View File
@@ -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(
+32 -7
View File
@@ -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())...),
}
}
+24 -1
View File
@@ -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(
+18 -3
View File
@@ -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 {
+44 -16
View File
@@ -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),
),
},
@@ -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{},
}
}
@@ -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")
}
}