From 765027e0d7fb05d5249915ed5c472160f237d91f Mon Sep 17 00:00:00 2001 From: Silvan <27845747+adlerhurst@users.noreply.github.com> Date: Wed, 23 Sep 2026 07:17:09 +0200 Subject: [PATCH] feat(eventstore): remove application constraints by app owner (#12798) # Which Problems Are Solved Deleting an application loads the SAML config only to learn the entity id, then deletes the `entity_ids` unique constraint by that field. The app name constraint is deleted by `name:projectID`. Neither row is tagged with the app id, so the delete cannot use the owner index. # How the Problems Are Solved `appname` and `entity_ids` rows are tagged `app:`, in addition to the existing org and project tags. - Live writes stamp the tag on application add/rename and on SAML entity-id add/change. - Setup 79 appends `app:` on the next version run, including rows that already have `org` and `project` owners. - When the owners backfill is finalized, `RemoveApplication` emits `RemoveByOwner(app, appID)` and does not read the SAML config. - Before that backfill, removal still deletes by the app name and, for SAML apps, by the entity id. Renames stay field deletes. Project names and roles stay tagged with org and project only. # Additional Changes - `UniqueConstraintOwnerApp` is added next to the other owner kinds. # Additional Context - Base branch: `v4.x` - Follow-up for the unique-constraint owners work (setup 78/79) Verified locally: - `go test ./cmd/setup -run TestBackfillUniqueConstraintOwnersStmts` - `go test ./internal/command -run 'TestCommandSide_RemoveApplication|TestCommandSide_RemoveDynamicOIDCClient'` - `go test ./internal/eventstore ./internal/v2/eventstore`
Open in Web Open in Cursor 
--------- Co-authored-by: Cursor Agent Co-authored-by: Silvan --- cmd/setup/79/07_appname.sql | 9 +++++-- cmd/setup/79/09_entity_ids.sql | 9 +++++-- cmd/setup/79_test.go | 4 +-- internal/command/project_application.go | 20 ++++++++++---- internal/command/project_application_test.go | 27 +++++++++++++++++++ internal/eventstore/unique_constraint.go | 1 + internal/repository/project/application.go | 22 +++++++++++++-- internal/repository/project/project.go | 4 +++ internal/repository/project/saml_config.go | 4 +-- .../unique_constraint_owners_test.go | 4 +-- internal/v2/eventstore/unique_constraint.go | 1 + 11 files changed, 88 insertions(+), 17 deletions(-) diff --git a/cmd/setup/79/07_appname.sql b/cmd/setup/79/07_appname.sql index a4d7b92651..ec58148eb8 100644 --- a/cmd/setup/79/07_appname.sql +++ b/cmd/setup/79/07_appname.sql @@ -1,8 +1,13 @@ -- unique_field: name:projectID (project.NewAddApplicationUniqueConstraint) UPDATE eventstore.unique_constraints uc -SET owners = ARRAY['org:' || a.resource_owner, 'project:' || a.project_id] +SET owners = ( + CASE WHEN uc.owners = '{}' + THEN ARRAY['org:' || a.resource_owner, 'project:' || a.project_id] + ELSE uc.owners + END +) || ARRAY['app:' || a.id] FROM {{.apps}} a WHERE uc.unique_type = 'appname' AND uc.instance_id = a.instance_id - AND uc.owners = '{}' + AND NOT (uc.owners @> ARRAY['app:' || a.id]) AND uc.unique_field IN (a.name || ':' || a.project_id, lower(a.name || ':' || a.project_id)); diff --git a/cmd/setup/79/09_entity_ids.sql b/cmd/setup/79/09_entity_ids.sql index cd741bd139..f0394eb9ae 100644 --- a/cmd/setup/79/09_entity_ids.sql +++ b/cmd/setup/79/09_entity_ids.sql @@ -1,9 +1,14 @@ -- unique_field: entityID (project.NewAddSAMLConfigEntityIDUniqueConstraint) UPDATE eventstore.unique_constraints uc -SET owners = ARRAY['org:' || a.resource_owner, 'project:' || a.project_id] +SET owners = ( + CASE WHEN uc.owners = '{}' + THEN ARRAY['org:' || a.resource_owner, 'project:' || a.project_id] + ELSE uc.owners + END +) || ARRAY['app:' || a.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 NOT (uc.owners @> ARRAY['app:' || a.id]) AND uc.unique_field IN (s.entity_id, lower(s.entity_id)); diff --git a/cmd/setup/79_test.go b/cmd/setup/79_test.go index e935373350..5ef876b6f6 100644 --- a/cmd/setup/79_test.go +++ b/cmd/setup/79_test.go @@ -32,9 +32,9 @@ func TestBackfillUniqueConstraintOwnersStmts(t *testing.T) { {"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}, + {"07_appname.sql", "appname", "a.name || ':' || a.project_id", projection.AppProjectionTable, []string{"'app:' || a.id"}}, {"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}}, + {"09_entity_ids.sql", "entity_ids", "s.entity_id", projection.AppProjectionTable, []string{projection.AppSAMLTable, "'app:' || a.id"}}, {"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"}}, diff --git a/internal/command/project_application.go b/internal/command/project_application.go index 465b12e1e1..34a44b9d9d 100644 --- a/internal/command/project_application.go +++ b/internal/command/project_application.go @@ -139,13 +139,23 @@ func (c *Commands) RemoveApplication(ctx context.Context, projectID, appID, reso projectAgg := ProjectAggregateFromWriteModel(&existingApp.WriteModel) - entityID := "" - samlWriteModel, err := c.getSAMLAppWriteModel(ctx, projectID, appID, resourceOwner) - if err == nil && samlWriteModel.State != domain.AppStateUnspecified && samlWriteModel.State != domain.AppStateRemoved && samlWriteModel.saml { - entityID = samlWriteModel.EntityID + ownerDeleteReady, err := c.isUniqueConstraintOwnerDeleteReady(ctx) + if err != nil { + return nil, err + } + var removedEvent *project.ApplicationRemovedEvent + if ownerDeleteReady { + removedEvent = project.NewApplicationRemovedByOwnerEvent(ctx, projectAgg, appID) + } else { + entityID := "" + samlWriteModel, err := c.getSAMLAppWriteModel(ctx, projectID, appID, resourceOwner) + if err == nil && samlWriteModel.State != domain.AppStateUnspecified && samlWriteModel.State != domain.AppStateRemoved && samlWriteModel.saml { + entityID = samlWriteModel.EntityID + } + removedEvent = project.NewApplicationRemovedEvent(ctx, projectAgg, appID, existingApp.Name, entityID) } - pushedEvents, err := c.eventstore.Push(ctx, project.NewApplicationRemovedEvent(ctx, projectAgg, appID, existingApp.Name, entityID)) + pushedEvents, err := c.eventstore.Push(ctx, removedEvent) if err != nil { return nil, err } diff --git a/internal/command/project_application_test.go b/internal/command/project_application_test.go index a67e6886ed..174fac7ff0 100644 --- a/internal/command/project_application_test.go +++ b/internal/command/project_application_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" @@ -678,3 +679,29 @@ func TestCommandSide_RemoveApplication(t *testing.T) { }) } } + +func TestCommandSide_RemoveApplicationOwnerDeleteReady(t *testing.T) { + r := &Commands{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "app1", + "app", + )), + ), + expectFilter(), + expectPush( + project.NewApplicationRemovedByOwnerEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "app1", + ), + ), + )(t), + checkPermission: newMockPermissionCheckAllowed(), + ownerDeleteReady: func(context.Context) (bool, error) { return true, nil }, + } + got, err := r.RemoveApplication(context.Background(), "project1", "app1", "org1") + require.NoError(t, err) + assertObjectDetails(t, &domain.ObjectDetails{ResourceOwner: "org1"}, got) +} diff --git a/internal/eventstore/unique_constraint.go b/internal/eventstore/unique_constraint.go index 0c0af463c7..b3d1a4ab8c 100644 --- a/internal/eventstore/unique_constraint.go +++ b/internal/eventstore/unique_constraint.go @@ -32,6 +32,7 @@ const ( UniqueConstraintOwnerIDP = "idp" UniqueConstraintOwnerProject = "project" UniqueConstraintOwnerGrant = "grant" + UniqueConstraintOwnerApp = "app" // UniqueConstraintOwnersBackfillStep is the setup migration that stamps owners from projections. UniqueConstraintOwnersBackfillStep = "79_backfill_unique_constraint_owners" diff --git a/internal/repository/project/application.go b/internal/repository/project/application.go index 24b2d527c2..2fd85c4db7 100644 --- a/internal/repository/project/application.go +++ b/internal/repository/project/application.go @@ -43,7 +43,7 @@ func (e *ApplicationAddedEvent) Payload() interface{} { } func (e *ApplicationAddedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { - return []*eventstore.UniqueConstraint{NewAddApplicationUniqueConstraint(e.Name, e.Aggregate().ID).WithOwners(projectOwnerTags(e.Aggregate())...)} + return []*eventstore.UniqueConstraint{NewAddApplicationUniqueConstraint(e.Name, e.Aggregate().ID).WithOwners(appOwnerTags(e.Aggregate(), e.AppID)...)} } 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).WithOwners(projectOwnerTags(e.Aggregate())...), + NewAddApplicationUniqueConstraint(e.Name, e.Aggregate().ID).WithOwners(appOwnerTags(e.Aggregate(), e.AppID)...), } } @@ -217,6 +217,8 @@ type ApplicationRemovedEvent struct { AppID string `json:"appId,omitempty"` name string entityID string + // removeByOwner deletes appname and entity_ids by app id after setup 79 has stamped that tag. + removeByOwner bool } func (e *ApplicationRemovedEvent) Payload() interface{} { @@ -224,6 +226,11 @@ func (e *ApplicationRemovedEvent) Payload() interface{} { } func (e *ApplicationRemovedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + if e.removeByOwner { + return []*eventstore.UniqueConstraint{ + eventstore.NewRemoveUniqueConstraintsByOwner(eventstore.UniqueConstraintOwnerApp, e.AppID), + } + } remove := []*eventstore.UniqueConstraint{NewRemoveApplicationUniqueConstraint(e.name, e.Aggregate().ID)} if e.entityID != "" { remove = append(remove, NewRemoveSAMLConfigEntityIDUniqueConstraint(e.entityID)) @@ -250,6 +257,17 @@ func NewApplicationRemovedEvent( } } +// NewApplicationRemovedByOwnerEvent removes every unique constraint tagged with the app id. +func NewApplicationRemovedByOwnerEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + appID string, +) *ApplicationRemovedEvent { + removed := NewApplicationRemovedEvent(ctx, aggregate, appID, "", "") + removed.removeByOwner = true + return removed +} + func ApplicationRemovedEventMapper(event eventstore.Event) (eventstore.Event, error) { e := &ApplicationRemovedEvent{ BaseEvent: *eventstore.BaseEventFromRepo(event), diff --git a/internal/repository/project/project.go b/internal/repository/project/project.go index 848438e00d..3344708905 100644 --- a/internal/repository/project/project.go +++ b/internal/repository/project/project.go @@ -60,6 +60,10 @@ func projectOwnerTags(agg *eventstore.Aggregate) []string { } } +func appOwnerTags(agg *eventstore.Aggregate, appID string) []string { + return append(projectOwnerTags(agg), eventstore.OwnerTag(eventstore.UniqueConstraintOwnerApp, appID)) +} + func grantOwnerTags(agg *eventstore.Aggregate, grantID, grantedOrgID string) []string { return []string{ eventstore.OwnerTag(eventstore.UniqueConstraintOwnerOrg, agg.ResourceOwner), diff --git a/internal/repository/project/saml_config.go b/internal/repository/project/saml_config.go index 06f621ab5e..eefe8dfadd 100644 --- a/internal/repository/project/saml_config.go +++ b/internal/repository/project/saml_config.go @@ -43,7 +43,7 @@ func NewRemoveSAMLConfigEntityIDUniqueConstraint(entityID string) *eventstore.Un } func (e *SAMLConfigAddedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { - return []*eventstore.UniqueConstraint{NewAddSAMLConfigEntityIDUniqueConstraint(e.EntityID).WithOwners(projectOwnerTags(e.Aggregate())...)} + return []*eventstore.UniqueConstraint{NewAddSAMLConfigEntityIDUniqueConstraint(e.EntityID).WithOwners(appOwnerTags(e.Aggregate(), e.AppID)...)} } func NewSAMLConfigAddedEvent( @@ -104,7 +104,7 @@ func (e *SAMLConfigChangedEvent) UniqueConstraints() []*eventstore.UniqueConstra if e.EntityID != "" { return []*eventstore.UniqueConstraint{ NewRemoveSAMLConfigEntityIDUniqueConstraint(e.oldEntityID), - NewAddSAMLConfigEntityIDUniqueConstraint(e.EntityID).WithOwners(projectOwnerTags(e.Aggregate())...), + NewAddSAMLConfigEntityIDUniqueConstraint(e.EntityID).WithOwners(appOwnerTags(e.Aggregate(), e.AppID)...), } } return nil diff --git a/internal/repository/unique_constraint_owners_test.go b/internal/repository/unique_constraint_owners_test.go index 7ded41ca30..dc9ad63db5 100644 --- a/internal/repository/unique_constraint_owners_test.go +++ b/internal/repository/unique_constraint_owners_test.go @@ -53,7 +53,7 @@ func TestUniqueConstraintOwners(t *testing.T) { { 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"}}, + want: [][]string{{"org:org-1", "project:project-1", "app:app-1"}}, }, { name: "project role", @@ -63,7 +63,7 @@ func TestUniqueConstraintOwners(t *testing.T) { { 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"}}, + want: [][]string{{"org:org-1", "project:project-1", "app:app-1"}}, }, { name: "project name", diff --git a/internal/v2/eventstore/unique_constraint.go b/internal/v2/eventstore/unique_constraint.go index 96f7cdb498..e33f399014 100644 --- a/internal/v2/eventstore/unique_constraint.go +++ b/internal/v2/eventstore/unique_constraint.go @@ -32,6 +32,7 @@ const ( UniqueConstraintOwnerIDP = "idp" UniqueConstraintOwnerProject = "project" UniqueConstraintOwnerGrant = "grant" + UniqueConstraintOwnerApp = "app" ) func (f UniqueConstraintAction) Valid() bool {