From 58612a6ef773afe7eb286c223328e0d5a19b2f74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Fri, 12 Dec 2025 09:09:09 +0100 Subject: [PATCH] fix(fields): sync membership roles from projections (#11178) # Which Problems Are Solved Zitadel v4.7.2 fixed a security issue by switching to the permission v2 framework for user APIs. It appears that systems that are running since before v2.68 that were affected by a precision bug in the eventstore, which was fixed in that version. The precision bug results in certain events being "skipped" while being projected into the fields table, used by the new permission system. This caused certain membership roles to be missing, resulting in empty user lists when executed by the affected member. The permission system basically finds no matching memberships and therefore returns no users at all. # How the Problems Are Solved After research we concluded that the legacy membership projections are projected correctly. This PR synchronizes the projected state into the fields table. As the membership roles are not marked unique, all rows are first deleted and then the correct membership roles are then inserted. The operation happens in a single transaction, during which the fields table will remain locked for modifications. This to prevent possible concurrent modifications to membership states. # Additional Changes - none # Additional Context - Introduced in https://github.com/zitadel/zitadel/commit/0e17d0005a98ccbf92139961ef702ef03208ffd3 - Released in [v4.7.2](https://github.com/zitadel/zitadel/releases/tag/v4.7.2) - Related: https://github.com/zitadel/zitadel/issues/8863 --------- Co-authored-by: Silvan <27845747+adlerhurst@users.noreply.github.com> --- cmd/setup/67.go | 27 +++++++++++++++++ cmd/setup/67.sql | 71 +++++++++++++++++++++++++++++++++++++++++++++ cmd/setup/config.go | 1 + cmd/setup/setup.go | 2 ++ 4 files changed, 101 insertions(+) create mode 100644 cmd/setup/67.go create mode 100644 cmd/setup/67.sql diff --git a/cmd/setup/67.go b/cmd/setup/67.go new file mode 100644 index 000000000..9859a6db1 --- /dev/null +++ b/cmd/setup/67.go @@ -0,0 +1,27 @@ +package setup + +import ( + "context" + _ "embed" + + "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/eventstore" +) + +var ( + //go:embed 67.sql + syncMemberRoleFields string +) + +type SyncMemberRoleFields struct { + dbClient *database.DB +} + +func (mig *SyncMemberRoleFields) Execute(ctx context.Context, _ eventstore.Event) error { + _, err := mig.dbClient.ExecContext(ctx, syncMemberRoleFields) + return err +} + +func (mig *SyncMemberRoleFields) String() string { + return "67_sync_member_role_fields" +} diff --git a/cmd/setup/67.sql b/cmd/setup/67.sql new file mode 100644 index 000000000..ac5a060b0 --- /dev/null +++ b/cmd/setup/67.sql @@ -0,0 +1,71 @@ +-- Make sure no other transaction is writing to fields table. +-- Will block transactions with events that modify the fields table. +-- SELECT is still possible during this time. +LOCK TABLE eventstore.fields IN SHARE ROW EXCLUSIVE MODE; + +DELETE FROM eventstore.fields + WHERE object_type IN ( + 'instance_member_role', + 'org_member_role', + 'project_member_role' + ); + +INSERT INTO eventstore.fields( + instance_id, + resource_owner, + aggregate_type, + aggregate_id, + object_type, + object_id, + object_revision, + field_name, + value, + value_must_be_unique, + should_index +) + +SELECT + instance_id, + resource_owner, + 'instance' as aggregate_type, + id as aggregate_id, + 'instance_member_role' as object_type, + user_id as object_id, + 1::smallint as object_revision, + 'instance_role' as field_name, + to_jsonb(unnest(roles)) as value, + false as value_must_be_unique, + true as should_index +FROM projections.instance_members4 + +UNION ALL + +SELECT + instance_id, + resource_owner, + 'org' as aggregate_type, + org_id as aggregate_id, + 'org_member_role' as object_type, + user_id as object_id, + 1::smallint as object_revision, + 'org_role' as field_name, + to_jsonb(unnest(roles)) as value, + false as value_must_be_unique, + true as should_index +FROM projections.org_members4 + +UNION ALL + +SELECT + instance_id, + resource_owner, + 'project' as aggregate_type, + project_id as aggregate_id, + 'project_member_role' as object_type, + user_id as object_id, + 1::smallint as object_revision, + 'project_role' as field_name, + to_jsonb(unnest(roles)) as value, + false as value_must_be_unique, + true as should_index +FROM projections.project_members4; diff --git a/cmd/setup/config.go b/cmd/setup/config.go index ec22c5fd4..33a39c4fe 100644 --- a/cmd/setup/config.go +++ b/cmd/setup/config.go @@ -163,6 +163,7 @@ type Steps struct { s64ChangePushPosition *ChangePushPosition s65FixUserMetadata5Index *FixUserMetadata5Index s66SessionRecoveryCodeCheckedAt *SessionRecoveryCodeCheckedAt + s67SyncMemberRoleFields *SyncMemberRoleFields } func MustNewSteps(v *viper.Viper) *Steps { diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index e5bcd1268..76a1cd4d3 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -224,6 +224,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s64ChangePushPosition = &ChangePushPosition{dbClient: dbClient} steps.s65FixUserMetadata5Index = &FixUserMetadata5Index{dbClient: dbClient} steps.s66SessionRecoveryCodeCheckedAt = &SessionRecoveryCodeCheckedAt{dbClient: dbClient} + steps.s67SyncMemberRoleFields = &SyncMemberRoleFields{dbClient: dbClient} err = projection.Create(ctx, dbClient, eventstoreClient, config.Projections, nil, nil, nil) logging.OnError(err).Fatal("unable to start projections") @@ -340,6 +341,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s48Apps7SAMLConfigsLoginVersion, steps.s59SetupWebkeys, // this step needs commands. steps.s66SessionRecoveryCodeCheckedAt, + steps.s67SyncMemberRoleFields, } { setupErr = executeMigration(ctx, eventstoreClient, step, "migration failed") if setupErr != nil {