feat(eventstore): remove application constraints by app owner (#12798)

<!-- CURSOR_AGENT_PR_BODY_BEGIN -->
# 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:<appID>`, 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:<appID>` 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`

<!-- CURSOR_AGENT_PR_BODY_END -->

<div><a
href="https://cursor.com/agents/bc-31bfb5c4-5888-43f0-85cf-af1af097ca30?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-31bfb5c4-5888-43f0-85cf-af1af097ca30&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 <cursoragent@cursor.com>
Co-authored-by: Silvan <adlerhurst@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-09-23 07:17:09 +02:00
1 parent 63085396e1
commit 765027e0d7
11 files changed
+88 -17

No files matched your search

+7 -2
View File
@@ -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));
+7 -2
View File
@@ -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));
+2 -2
View File
@@ -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"}},
+15 -5
View File
@@ -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
}
@@ -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)
}
+1
View File
@@ -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"
+20 -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).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),
+4
View File
@@ -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),
+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).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
@@ -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",
@@ -32,6 +32,7 @@ const (
UniqueConstraintOwnerIDP = "idp"
UniqueConstraintOwnerProject = "project"
UniqueConstraintOwnerGrant = "grant"
UniqueConstraintOwnerApp = "app"
)
func (f UniqueConstraintAction) Valid() bool {