diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index 74ffffafcd..4176747ee6 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -1132,6 +1132,7 @@ DefaultInstance: LoginDefaultOrg: true # ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINDEFAULTORG # TriggerIntrospectionProjections: false # ZITADEL_DEFAULTINSTANCE_FEATURES_TRIGGERINTROSPECTIONPROJECTIONS # LegacyIntrospection: false # ZITADEL_DEFAULTINSTANCE_FEATURES_LEGACYINTROSPECTION + # PermissionCheckV2: false # ZITADEL_DEFAULTINSTANCE_FEATURES_PERMISSIONCHECKV2 Limits: # AuditLogRetention limits the number of events that can be queried via the events API by their age. # A value of "0s" means that all events are available. @@ -1195,6 +1196,9 @@ InternalAuthZ: # Configure the RolePermissionMappings by environment variable using JSON notation: # ZITADEL_INTERNALAUTHZ_ROLEPERMISSIONMAPPINGS='[{"role": "IAM_OWNER", "permissions": ["iam.write"]}, {"role": "ORG_OWNER", "permissions": ["org.write"]}]' # Beware that if you configure the RolePermissionMappings by environment variable, all the default RolePermissionMappings are lost. + # + # Warning: RolePermissionMappings are synhronized to the database. + # Changes here will only be applied after running `zitadel setup` or `zitadel start-from-setup`. RolePermissionMappings: - Role: "SYSTEM_OWNER" Permissions: diff --git a/cmd/setup/46.go b/cmd/setup/46.go new file mode 100644 index 0000000000..e48b16e4b0 --- /dev/null +++ b/cmd/setup/46.go @@ -0,0 +1,39 @@ +package setup + +import ( + "context" + "embed" + "fmt" + + "github.com/zitadel/logging" + + "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/eventstore" +) + +type InitPermissionFunctions struct { + eventstoreClient *database.DB +} + +var ( + //go:embed 46/*.sql + permissionFunctions embed.FS +) + +func (mig *InitPermissionFunctions) Execute(ctx context.Context, _ eventstore.Event) error { + statements, err := readStatements(permissionFunctions, "46", "") + if err != nil { + return err + } + for _, stmt := range statements { + logging.WithFields("file", stmt.file, "migration", mig.String()).Info("execute statement") + if _, err := mig.eventstoreClient.ExecContext(ctx, stmt.query); err != nil { + return fmt.Errorf("%s %s: %w", mig.String(), stmt.file, err) + } + } + return nil +} + +func (*InitPermissionFunctions) String() string { + return "46_init_permission_functions" +} diff --git a/cmd/setup/46/01-role_permissions_view.sql b/cmd/setup/46/01-role_permissions_view.sql new file mode 100644 index 0000000000..f0a8413125 --- /dev/null +++ b/cmd/setup/46/01-role_permissions_view.sql @@ -0,0 +1,6 @@ +CREATE OR REPLACE VIEW eventstore.role_permissions AS +SELECT instance_id, aggregate_id, object_id as role, text_value as permission +FROM eventstore.fields +WHERE aggregate_type = 'permission' +AND object_type = 'role_permission' +AND field_name = 'permission'; diff --git a/cmd/setup/46/02-instance_orgs_view.sql b/cmd/setup/46/02-instance_orgs_view.sql new file mode 100644 index 0000000000..aa59fcde6a --- /dev/null +++ b/cmd/setup/46/02-instance_orgs_view.sql @@ -0,0 +1,6 @@ +CREATE OR REPLACE VIEW eventstore.instance_orgs AS +SELECT instance_id, aggregate_id as org_id +FROM eventstore.fields +WHERE aggregate_type = 'org' +AND object_type = 'org' +AND field_name = 'state'; diff --git a/cmd/setup/46/03-instance_members_view.sql b/cmd/setup/46/03-instance_members_view.sql new file mode 100644 index 0000000000..cf47610f42 --- /dev/null +++ b/cmd/setup/46/03-instance_members_view.sql @@ -0,0 +1,6 @@ +CREATE OR REPLACE VIEW eventstore.instance_members AS +SELECT instance_id, object_id as user_id, text_value as role +FROM eventstore.fields +WHERE aggregate_type = 'instance' +AND object_type = 'instance_member_role' +AND field_name = 'instance_role'; diff --git a/cmd/setup/46/04-org_members_view.sql b/cmd/setup/46/04-org_members_view.sql new file mode 100644 index 0000000000..7477d9a816 --- /dev/null +++ b/cmd/setup/46/04-org_members_view.sql @@ -0,0 +1,6 @@ +CREATE OR REPLACE VIEW eventstore.org_members AS +SELECT instance_id, aggregate_id as org_id, object_id as user_id, text_value as role +FROM eventstore.fields +WHERE aggregate_type = 'org' +AND object_type = 'org_member_role' +AND field_name = 'org_role'; diff --git a/cmd/setup/46/05-project_members_view.sql b/cmd/setup/46/05-project_members_view.sql new file mode 100644 index 0000000000..0eed48cec3 --- /dev/null +++ b/cmd/setup/46/05-project_members_view.sql @@ -0,0 +1,6 @@ +CREATE OR REPLACE VIEW eventstore.project_members AS +SELECT instance_id, aggregate_id as project_id, object_id as user_id, text_value as role +FROM eventstore.fields +WHERE aggregate_type = 'project' +AND object_type = 'project_member_role' +AND field_name = 'project_role'; diff --git a/cmd/setup/46/06-permitted_orgs_function.sql b/cmd/setup/46/06-permitted_orgs_function.sql new file mode 100644 index 0000000000..55d63c1a19 --- /dev/null +++ b/cmd/setup/46/06-permitted_orgs_function.sql @@ -0,0 +1,50 @@ +CREATE OR REPLACE FUNCTION eventstore.permitted_orgs( + instanceId TEXT + , userId TEXT + , perm TEXT + + , org_ids OUT TEXT[] +) + LANGUAGE 'plpgsql' + STABLE +AS $$ +DECLARE + matched_roles TEXT[]; -- roles containing permission +BEGIN + SELECT array_agg(rp.role) INTO matched_roles + FROM eventstore.role_permissions rp + WHERE rp.instance_id = instanceId + AND rp.permission = perm; + + -- First try if the permission was granted thru an instance-level role + DECLARE + has_instance_permission bool; + BEGIN + SELECT true INTO has_instance_permission + FROM eventstore.instance_members im + WHERE im.role = ANY(matched_roles) + AND im.instance_id = instanceId + AND im.user_id = userId + LIMIT 1; + + IF has_instance_permission THEN + -- Return all organizations + SELECT array_agg(o.org_id) INTO org_ids + FROM eventstore.instance_orgs o + WHERE o.instance_id = instanceId; + RETURN; + END IF; + END; + + -- Return the organizations where permission were granted thru org-level roles + SELECT array_agg(org_id) INTO org_ids + FROM ( + SELECT DISTINCT om.org_id + FROM eventstore.org_members om + WHERE om.role = ANY(matched_roles) + AND om.instance_id = instanceID + AND om.user_id = userId + ); + RETURN; +END; +$$; diff --git a/cmd/setup/config.go b/cmd/setup/config.go index 0a5493b771..6d9443fae0 100644 --- a/cmd/setup/config.go +++ b/cmd/setup/config.go @@ -87,6 +87,9 @@ func MustNewConfig(v *viper.Viper) *Config { id.Configure(config.Machine) + // Copy the global role permissions mappings to the instance until we allow instance-level configuration over the API. + config.DefaultInstance.RolePermissionMappings = config.InternalAuthZ.RolePermissionMappings + return config } @@ -131,6 +134,7 @@ type Steps struct { s43CreateFieldsDomainIndex *CreateFieldsDomainIndex s44ReplaceCurrentSequencesIndex *ReplaceCurrentSequencesIndex s45CorrectProjectOwners *CorrectProjectOwners + s46InitPermissionFunctions *InitPermissionFunctions } func MustNewSteps(v *viper.Viper) *Steps { diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index d55ea0f3fe..33dba00602 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -174,6 +174,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s43CreateFieldsDomainIndex = &CreateFieldsDomainIndex{dbClient: queryDBClient} steps.s44ReplaceCurrentSequencesIndex = &ReplaceCurrentSequencesIndex{dbClient: esPusherDBClient} steps.s45CorrectProjectOwners = &CorrectProjectOwners{eventstore: eventstoreClient} + steps.s46InitPermissionFunctions = &InitPermissionFunctions{eventstoreClient: esPusherDBClient} err = projection.Create(ctx, projectionDBClient, eventstoreClient, config.Projections, nil, nil, nil) logging.OnError(err).Fatal("unable to start projections") @@ -196,6 +197,10 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) &FillFieldsForInstanceDomains{ eventstore: eventstoreClient, }, + &SyncRolePermissions{ + eventstore: eventstoreClient, + rolePermissionMappings: config.InternalAuthZ.RolePermissionMappings, + }, } for _, step := range []migration.Migration{ @@ -229,6 +234,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s38BackChannelLogoutNotificationStart, steps.s44ReplaceCurrentSequencesIndex, steps.s45CorrectProjectOwners, + steps.s46InitPermissionFunctions, } { mustExecuteMigration(ctx, eventstoreClient, step, "migration failed") } diff --git a/cmd/setup/sync_role_permissions.go b/cmd/setup/sync_role_permissions.go new file mode 100644 index 0000000000..b38b075d82 --- /dev/null +++ b/cmd/setup/sync_role_permissions.go @@ -0,0 +1,134 @@ +package setup + +import ( + "context" + "database/sql" + _ "embed" + "fmt" + "strings" + + "github.com/zitadel/logging" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/instance" + "github.com/zitadel/zitadel/internal/repository/permission" +) + +var ( + //go:embed sync_role_permissions.sql + getRolePermissionOperationsQuery string +) + +// SyncRolePermissions is a repeatable step which synchronizes the InternalAuthZ +// RolePermissionMappings from the configuration to the database. +// This is needed until role permissions are manageable over the API. +type SyncRolePermissions struct { + eventstore *eventstore.Eventstore + rolePermissionMappings []authz.RoleMapping +} + +func (mig *SyncRolePermissions) Execute(ctx context.Context, _ eventstore.Event) error { + if err := mig.executeSystem(ctx); err != nil { + return err + } + return mig.executeInstances(ctx) +} + +func (mig *SyncRolePermissions) executeSystem(ctx context.Context) error { + logging.WithFields("migration", mig.String()).Info("prepare system role permission sync events") + + target := rolePermissionMappingsToDatabaseMap(mig.rolePermissionMappings, true) + cmds, err := mig.synchronizeCommands(ctx, "SYSTEM", target) + if err != nil { + return err + } + events, err := mig.eventstore.Push(ctx, cmds...) + if err != nil { + return err + } + + logging.WithFields("migration", mig.String(), "pushed_events", len(events)).Info("pushed system role permission sync events") + return nil +} + +func (mig *SyncRolePermissions) executeInstances(ctx context.Context) error { + instances, err := mig.eventstore.InstanceIDs( + ctx, + eventstore.NewSearchQueryBuilder(eventstore.ColumnsInstanceIDs). + OrderDesc(). + AddQuery(). + AggregateTypes(instance.AggregateType). + EventTypes(instance.InstanceAddedEventType). + Builder(). + ExcludeAggregateIDs(). + AggregateTypes(instance.AggregateType). + EventTypes(instance.InstanceRemovedEventType). + Builder(), + ) + if err != nil { + return err + } + target := rolePermissionMappingsToDatabaseMap(mig.rolePermissionMappings, false) + for i, instanceID := range instances { + logging.WithFields("instance_id", instanceID, "migration", mig.String(), "progress", fmt.Sprintf("%d/%d", i+1, len(instances))).Info("prepare instance role permission sync events") + cmds, err := mig.synchronizeCommands(ctx, instanceID, target) + if err != nil { + return err + } + events, err := mig.eventstore.Push(ctx, cmds...) + if err != nil { + return err + } + logging.WithFields("instance_id", instanceID, "migration", mig.String(), "pushed_events", len(events)).Info("pushed instance role permission sync events") + } + return nil +} + +// synchronizeCommands checks the current state of role permissions in the eventstore for the aggregate. +// It returns the commands required to reach the desired state passed in target. +// For system level permissions aggregateID must be set to `SYSTEM`, +// else it is the instance ID. +func (mig *SyncRolePermissions) synchronizeCommands(ctx context.Context, aggregateID string, target database.Map[[]string]) (cmds []eventstore.Command, err error) { + aggregate := permission.NewAggregate(aggregateID) + err = mig.eventstore.Client().QueryContext(ctx, func(rows *sql.Rows) error { + for rows.Next() { + var operation, role, perm string + if err := rows.Scan(&operation, &role, &perm); err != nil { + return err + } + logging.WithFields("aggregate_id", aggregateID, "migration", mig.String(), "operation", operation, "role", role, "permission", perm).Debug("sync role permission") + switch operation { + case "add": + cmds = append(cmds, permission.NewAddedEvent(ctx, aggregate, role, perm)) + case "remove": + cmds = append(cmds, permission.NewRemovedEvent(ctx, aggregate, role, perm)) + } + } + return rows.Close() + + }, getRolePermissionOperationsQuery, aggregateID, target) + if err != nil { + return nil, err + } + return cmds, err +} + +func (*SyncRolePermissions) String() string { + return "repeatable_sync_role_permissions" +} + +func (*SyncRolePermissions) Check(lastRun map[string]interface{}) bool { + return true +} + +func rolePermissionMappingsToDatabaseMap(mappings []authz.RoleMapping, system bool) database.Map[[]string] { + out := make(database.Map[[]string], len(mappings)) + for _, m := range mappings { + if system == strings.HasPrefix(m.Role, "SYSTEM") { + out[m.Role] = m.Permissions + } + } + return out +} diff --git a/cmd/setup/sync_role_permissions.sql b/cmd/setup/sync_role_permissions.sql new file mode 100644 index 0000000000..e7ce21cee7 --- /dev/null +++ b/cmd/setup/sync_role_permissions.sql @@ -0,0 +1,52 @@ +/* +This query creates a change set of permissions that need to be added or removed. +It compares the current state in the fields table (thru the role_permissions view) +against a passed role permission mapping as JSON, created from Zitadel's config: + +{ + "IAM_ADMIN_IMPERSONATOR": ["admin.impersonation", "impersonation"], + "IAM_END_USER_IMPERSONATOR": ["impersonation"], + "FOO_BAR": ["foo.bar", "bar.foo"] + } + +It uses an aggregate_id as first argument which may be an instance_id or 'SYSTEM' +for system level permissions. +*/ +WITH target AS ( + -- unmarshal JSON representation into flattened tabular data + SELECT + key AS role, + jsonb_array_elements_text(value) AS permission + FROM jsonb_each($2::jsonb) +), add AS ( + -- find all role permissions that exist in `target` and not in `role_permissions` + SELECT t.role, t.permission + FROM eventstore.role_permissions p + RIGHT JOIN target t + ON p.aggregate_id = $1::text + AND p.role = t.role + AND p.permission = t.permission + WHERE p.role IS NULL +), remove AS ( + -- find all role permissions that exist `role_permissions` and not in `target` + SELECT p.role, p.permission + FROM eventstore.role_permissions p + LEFT JOIN target t + ON p.role = t.role + AND p.permission = t.permission + WHERE p.aggregate_id = $1::text + AND t.role IS NULL +) +-- return the required operations +SELECT + 'add' AS operation, + role, + permission +FROM add +UNION ALL +SELECT + 'remove' AS operation, + role, + permission +FROM remove +; diff --git a/cmd/start/config.go b/cmd/start/config.go index d63b8a319a..910759b653 100644 --- a/cmd/start/config.go +++ b/cmd/start/config.go @@ -127,5 +127,8 @@ func MustNewConfig(v *viper.Viper) *Config { id.Configure(config.Machine) actions.SetHTTPConfig(&config.Actions.HTTP) + // Copy the global role permissions mappings to the instance until we allow instance-level configuration over the API. + config.DefaultInstance.RolePermissionMappings = config.InternalAuthZ.RolePermissionMappings + return config } diff --git a/internal/api/grpc/feature/v2/converter.go b/internal/api/grpc/feature/v2/converter.go index 109d2d1e53..fee4450ce2 100644 --- a/internal/api/grpc/feature/v2/converter.go +++ b/internal/api/grpc/feature/v2/converter.go @@ -29,6 +29,7 @@ func systemFeaturesToCommand(req *feature_pb.SetSystemFeaturesRequest) (*command DisableUserTokenEvent: req.DisableUserTokenEvent, EnableBackChannelLogout: req.EnableBackChannelLogout, LoginV2: loginV2, + PermissionCheckV2: req.PermissionCheckV2, }, nil } @@ -46,6 +47,7 @@ func systemFeaturesToPb(f *query.SystemFeatures) *feature_pb.GetSystemFeaturesRe DisableUserTokenEvent: featureSourceToFlagPb(&f.DisableUserTokenEvent), EnableBackChannelLogout: featureSourceToFlagPb(&f.EnableBackChannelLogout), LoginV2: loginV2ToLoginV2FlagPb(f.LoginV2), + PermissionCheckV2: featureSourceToFlagPb(&f.PermissionCheckV2), } } @@ -68,6 +70,7 @@ func instanceFeaturesToCommand(req *feature_pb.SetInstanceFeaturesRequest) (*com DisableUserTokenEvent: req.DisableUserTokenEvent, EnableBackChannelLogout: req.EnableBackChannelLogout, LoginV2: loginV2, + PermissionCheckV2: req.PermissionCheckV2, }, nil } @@ -87,6 +90,7 @@ func instanceFeaturesToPb(f *query.InstanceFeatures) *feature_pb.GetInstanceFeat DisableUserTokenEvent: featureSourceToFlagPb(&f.DisableUserTokenEvent), EnableBackChannelLogout: featureSourceToFlagPb(&f.EnableBackChannelLogout), LoginV2: loginV2ToLoginV2FlagPb(f.LoginV2), + PermissionCheckV2: featureSourceToFlagPb(&f.PermissionCheckV2), } } diff --git a/internal/api/grpc/feature/v2/converter_test.go b/internal/api/grpc/feature/v2/converter_test.go index f8b2c0006f..bf87dc959b 100644 --- a/internal/api/grpc/feature/v2/converter_test.go +++ b/internal/api/grpc/feature/v2/converter_test.go @@ -101,6 +101,10 @@ func Test_systemFeaturesToPb(t *testing.T) { BaseURI: &url.URL{Scheme: "https", Host: "login.com"}, }, }, + PermissionCheckV2: query.FeatureSource[bool]{ + Level: feature.LevelSystem, + Value: true, + }, } want := &feature_pb.GetSystemFeaturesResponse{ Details: &object.Details{ @@ -153,6 +157,10 @@ func Test_systemFeaturesToPb(t *testing.T) { BaseUri: gu.Ptr("https://login.com"), Source: feature_pb.Source_SOURCE_SYSTEM, }, + PermissionCheckV2: &feature_pb.FeatureFlag{ + Enabled: true, + Source: feature_pb.Source_SOURCE_SYSTEM, + }, } got := systemFeaturesToPb(arg) assert.Equal(t, want, got) @@ -252,6 +260,10 @@ func Test_instanceFeaturesToPb(t *testing.T) { BaseURI: &url.URL{Scheme: "https", Host: "login.com"}, }, }, + PermissionCheckV2: query.FeatureSource[bool]{ + Level: feature.LevelInstance, + Value: true, + }, } want := &feature_pb.GetInstanceFeaturesResponse{ Details: &object.Details{ @@ -312,6 +324,10 @@ func Test_instanceFeaturesToPb(t *testing.T) { BaseUri: gu.Ptr("https://login.com"), Source: feature_pb.Source_SOURCE_INSTANCE, }, + PermissionCheckV2: &feature_pb.FeatureFlag{ + Enabled: true, + Source: feature_pb.Source_SOURCE_INSTANCE, + }, } got := instanceFeaturesToPb(arg) assert.Equal(t, want, got) diff --git a/internal/command/instance.go b/internal/command/instance.go index c5ac4d8472..144378ce58 100644 --- a/internal/command/instance.go +++ b/internal/command/instance.go @@ -116,14 +116,15 @@ type InstanceSetup struct { MaxOTPAttempts uint64 ShouldShowLockoutFailure bool } - EmailTemplate []byte - MessageTexts []*domain.CustomMessageText - SMTPConfiguration *SMTPConfiguration - OIDCSettings *OIDCSettings - Quotas *SetQuotas - Features *InstanceFeatures - Limits *SetLimits - Restrictions *SetRestrictions + EmailTemplate []byte + MessageTexts []*domain.CustomMessageText + SMTPConfiguration *SMTPConfiguration + OIDCSettings *OIDCSettings + Quotas *SetQuotas + Features *InstanceFeatures + Limits *SetLimits + Restrictions *SetRestrictions + RolePermissionMappings []authz.RoleMapping } type SMTPConfiguration struct { @@ -379,6 +380,7 @@ func setupInstanceElements(instanceAgg *instance.Aggregate, setup *InstanceSetup setup.LabelPolicy.ThemeMode, ), prepareAddDefaultEmailTemplate(instanceAgg, setup.EmailTemplate), + prepareAddRolePermissions(instanceAgg, setup.RolePermissionMappings), } } diff --git a/internal/command/instance_features.go b/internal/command/instance_features.go index 44f122e98f..1f714671bd 100644 --- a/internal/command/instance_features.go +++ b/internal/command/instance_features.go @@ -29,6 +29,7 @@ type InstanceFeatures struct { DisableUserTokenEvent *bool EnableBackChannelLogout *bool LoginV2 *feature.LoginV2 + PermissionCheckV2 *bool } func (m *InstanceFeatures) isEmpty() bool { @@ -45,7 +46,8 @@ func (m *InstanceFeatures) isEmpty() bool { m.OIDCSingleV1SessionTermination == nil && m.DisableUserTokenEvent == nil && m.EnableBackChannelLogout == nil && - m.LoginV2 == nil + m.LoginV2 == nil && + m.PermissionCheckV2 == nil } func (c *Commands) SetInstanceFeatures(ctx context.Context, f *InstanceFeatures) (*domain.ObjectDetails, error) { diff --git a/internal/command/instance_features_model.go b/internal/command/instance_features_model.go index 8fa52318db..aaa8b2e53a 100644 --- a/internal/command/instance_features_model.go +++ b/internal/command/instance_features_model.go @@ -79,6 +79,7 @@ func (m *InstanceFeaturesWriteModel) Query() *eventstore.SearchQueryBuilder { feature_v2.InstanceDisableUserTokenEvent, feature_v2.InstanceEnableBackChannelLogout, feature_v2.InstanceLoginVersion, + feature_v2.InstancePermissionCheckV2, ). Builder().ResourceOwner(m.ResourceOwner) } @@ -129,6 +130,9 @@ func reduceInstanceFeature(features *InstanceFeatures, key feature.Key, value an features.EnableBackChannelLogout = &v case feature.KeyLoginV2: features.LoginV2 = value.(*feature.LoginV2) + case feature.KeyPermissionCheckV2: + v := value.(bool) + features.PermissionCheckV2 = &v } } @@ -148,5 +152,6 @@ func (wm *InstanceFeaturesWriteModel) setCommands(ctx context.Context, f *Instan cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.DisableUserTokenEvent, f.DisableUserTokenEvent, feature_v2.InstanceDisableUserTokenEvent) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.EnableBackChannelLogout, f.EnableBackChannelLogout, feature_v2.InstanceEnableBackChannelLogout) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.LoginV2, f.LoginV2, feature_v2.InstanceLoginVersion) + cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.PermissionCheckV2, f.PermissionCheckV2, feature_v2.InstancePermissionCheckV2) return cmds } diff --git a/internal/command/instance_permissions.go b/internal/command/instance_permissions.go new file mode 100644 index 0000000000..c46c8f7c4a --- /dev/null +++ b/internal/command/instance_permissions.go @@ -0,0 +1,29 @@ +package command + +import ( + "context" + "strings" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/command/preparation" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/instance" + "github.com/zitadel/zitadel/internal/repository/permission" +) + +func prepareAddRolePermissions(a *instance.Aggregate, roles []authz.RoleMapping) preparation.Validation { + return func() (preparation.CreateCommands, error) { + return func(ctx context.Context, _ preparation.FilterToQueryReducer) (cmds []eventstore.Command, _ error) { + aggregate := permission.NewAggregate(a.InstanceID) + for _, r := range roles { + if strings.HasPrefix(r.Role, "SYSTEM") { + continue + } + for _, p := range r.Permissions { + cmds = append(cmds, permission.NewAddedEvent(ctx, aggregate, r.Role, p)) + } + } + return cmds, nil + }, nil + } +} diff --git a/internal/command/system_features.go b/internal/command/system_features.go index eb10bba553..dc886de318 100644 --- a/internal/command/system_features.go +++ b/internal/command/system_features.go @@ -21,6 +21,7 @@ type SystemFeatures struct { DisableUserTokenEvent *bool EnableBackChannelLogout *bool LoginV2 *feature.LoginV2 + PermissionCheckV2 *bool } func (m *SystemFeatures) isEmpty() bool { @@ -35,7 +36,8 @@ func (m *SystemFeatures) isEmpty() bool { m.OIDCSingleV1SessionTermination == nil && m.DisableUserTokenEvent == nil && m.EnableBackChannelLogout == nil && - m.LoginV2 == nil + m.LoginV2 == nil && + m.PermissionCheckV2 == nil } func (c *Commands) SetSystemFeatures(ctx context.Context, f *SystemFeatures) (*domain.ObjectDetails, error) { diff --git a/internal/command/system_features_model.go b/internal/command/system_features_model.go index d656a6e266..15fc3e0bf0 100644 --- a/internal/command/system_features_model.go +++ b/internal/command/system_features_model.go @@ -70,6 +70,7 @@ func (m *SystemFeaturesWriteModel) Query() *eventstore.SearchQueryBuilder { feature_v2.SystemDisableUserTokenEvent, feature_v2.SystemEnableBackChannelLogout, feature_v2.SystemLoginVersion, + feature_v2.SystemPermissionCheckV2, ). Builder().ResourceOwner(m.ResourceOwner) } @@ -113,6 +114,9 @@ func reduceSystemFeature(features *SystemFeatures, key feature.Key, value any) { features.EnableBackChannelLogout = &v case feature.KeyLoginV2: features.LoginV2 = value.(*feature.LoginV2) + case feature.KeyPermissionCheckV2: + v := value.(bool) + features.PermissionCheckV2 = &v } } @@ -130,6 +134,7 @@ func (wm *SystemFeaturesWriteModel) setCommands(ctx context.Context, f *SystemFe cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.DisableUserTokenEvent, f.DisableUserTokenEvent, feature_v2.SystemDisableUserTokenEvent) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.EnableBackChannelLogout, f.EnableBackChannelLogout, feature_v2.SystemEnableBackChannelLogout) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.LoginV2, f.LoginV2, feature_v2.SystemLoginVersion) + cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.PermissionCheckV2, f.PermissionCheckV2, feature_v2.SystemPermissionCheckV2) return cmds } diff --git a/internal/feature/feature.go b/internal/feature/feature.go index 09fdf2ff52..d9a2d6352d 100644 --- a/internal/feature/feature.go +++ b/internal/feature/feature.go @@ -23,6 +23,7 @@ const ( KeyDisableUserTokenEvent KeyEnableBackChannelLogout KeyLoginV2 + KeyPermissionCheckV2 ) //go:generate enumer -type Level -transform snake -trimprefix Level @@ -52,6 +53,7 @@ type Features struct { DisableUserTokenEvent bool `json:"disable_user_token_event,omitempty"` EnableBackChannelLogout bool `json:"enable_back_channel_logout,omitempty"` LoginV2 LoginV2 `json:"login_v2,omitempty"` + PermissionCheckV2 bool `json:"permission_check_v2,omitempty"` } type ImprovedPerformanceType int32 diff --git a/internal/feature/key_enumer.go b/internal/feature/key_enumer.go index 462b751e6c..3a805df807 100644 --- a/internal/feature/key_enumer.go +++ b/internal/feature/key_enumer.go @@ -7,11 +7,11 @@ import ( "strings" ) -const _KeyName = "unspecifiedlogin_default_orgtrigger_introspection_projectionslegacy_introspectionuser_schematoken_exchangeactionsimproved_performanceweb_keydebug_oidc_parent_erroroidc_single_v1_session_terminationdisable_user_token_eventenable_back_channel_logoutlogin_v2" +const _KeyName = "unspecifiedlogin_default_orgtrigger_introspection_projectionslegacy_introspectionuser_schematoken_exchangeactionsimproved_performanceweb_keydebug_oidc_parent_erroroidc_single_v1_session_terminationdisable_user_token_eventenable_back_channel_logoutlogin_v2permission_check_v2" -var _KeyIndex = [...]uint8{0, 11, 28, 61, 81, 92, 106, 113, 133, 140, 163, 197, 221, 247, 255} +var _KeyIndex = [...]uint16{0, 11, 28, 61, 81, 92, 106, 113, 133, 140, 163, 197, 221, 247, 255, 274} -const _KeyLowerName = "unspecifiedlogin_default_orgtrigger_introspection_projectionslegacy_introspectionuser_schematoken_exchangeactionsimproved_performanceweb_keydebug_oidc_parent_erroroidc_single_v1_session_terminationdisable_user_token_eventenable_back_channel_logoutlogin_v2" +const _KeyLowerName = "unspecifiedlogin_default_orgtrigger_introspection_projectionslegacy_introspectionuser_schematoken_exchangeactionsimproved_performanceweb_keydebug_oidc_parent_erroroidc_single_v1_session_terminationdisable_user_token_eventenable_back_channel_logoutlogin_v2permission_check_v2" func (i Key) String() string { if i < 0 || i >= Key(len(_KeyIndex)-1) { @@ -38,9 +38,10 @@ func _KeyNoOp() { _ = x[KeyDisableUserTokenEvent-(11)] _ = x[KeyEnableBackChannelLogout-(12)] _ = x[KeyLoginV2-(13)] + _ = x[KeyPermissionCheckV2-(14)] } -var _KeyValues = []Key{KeyUnspecified, KeyLoginDefaultOrg, KeyTriggerIntrospectionProjections, KeyLegacyIntrospection, KeyUserSchema, KeyTokenExchange, KeyActions, KeyImprovedPerformance, KeyWebKey, KeyDebugOIDCParentError, KeyOIDCSingleV1SessionTermination, KeyDisableUserTokenEvent, KeyEnableBackChannelLogout, KeyLoginV2} +var _KeyValues = []Key{KeyUnspecified, KeyLoginDefaultOrg, KeyTriggerIntrospectionProjections, KeyLegacyIntrospection, KeyUserSchema, KeyTokenExchange, KeyActions, KeyImprovedPerformance, KeyWebKey, KeyDebugOIDCParentError, KeyOIDCSingleV1SessionTermination, KeyDisableUserTokenEvent, KeyEnableBackChannelLogout, KeyLoginV2, KeyPermissionCheckV2} var _KeyNameToValueMap = map[string]Key{ _KeyName[0:11]: KeyUnspecified, @@ -71,6 +72,8 @@ var _KeyNameToValueMap = map[string]Key{ _KeyLowerName[221:247]: KeyEnableBackChannelLogout, _KeyName[247:255]: KeyLoginV2, _KeyLowerName[247:255]: KeyLoginV2, + _KeyName[255:274]: KeyPermissionCheckV2, + _KeyLowerName[255:274]: KeyPermissionCheckV2, } var _KeyNames = []string{ @@ -88,6 +91,7 @@ var _KeyNames = []string{ _KeyName[197:221], _KeyName[221:247], _KeyName[247:255], + _KeyName[255:274], } // KeyString retrieves an enum value from the enum constants string name. diff --git a/internal/query/instance_features.go b/internal/query/instance_features.go index 4f06577a6d..646404ce6c 100644 --- a/internal/query/instance_features.go +++ b/internal/query/instance_features.go @@ -22,6 +22,7 @@ type InstanceFeatures struct { DisableUserTokenEvent FeatureSource[bool] EnableBackChannelLogout FeatureSource[bool] LoginV2 FeatureSource[*feature.LoginV2] + PermissionCheckV2 FeatureSource[bool] } func (q *Queries) GetInstanceFeatures(ctx context.Context, cascade bool) (_ *InstanceFeatures, err error) { diff --git a/internal/query/instance_features_model.go b/internal/query/instance_features_model.go index c7f273a24a..b9839bf359 100644 --- a/internal/query/instance_features_model.go +++ b/internal/query/instance_features_model.go @@ -75,6 +75,7 @@ func (m *InstanceFeaturesReadModel) Query() *eventstore.SearchQueryBuilder { feature_v2.InstanceDisableUserTokenEvent, feature_v2.InstanceEnableBackChannelLogout, feature_v2.InstanceLoginVersion, + feature_v2.InstancePermissionCheckV2, ). Builder().ResourceOwner(m.ResourceOwner) } @@ -139,6 +140,8 @@ func reduceInstanceFeatureSet[T any](features *InstanceFeatures, event *feature_ features.EnableBackChannelLogout.set(level, event.Value) case feature.KeyLoginV2: features.LoginV2.set(level, event.Value) + case feature.KeyPermissionCheckV2: + features.PermissionCheckV2.set(level, event.Value) } return nil } diff --git a/internal/query/permission.go b/internal/query/permission.go new file mode 100644 index 0000000000..96d7db6c6a --- /dev/null +++ b/internal/query/permission.go @@ -0,0 +1,35 @@ +package query + +import ( + "context" + "fmt" + + sq "github.com/Masterminds/squirrel" + "github.com/zitadel/logging" + + "github.com/zitadel/zitadel/internal/api/authz" +) + +const ( + // eventstore.permitted_orgs(instanceid text, userid text, perm text) + wherePermittedOrgsClause = "%s = ANY(eventstore.permitted_orgs(?, ?, ?))" +) + +// wherePermittedOrgs sets a `WHERE` clause to the query that filters the orgs +// for which the authenticated user has the requested permission for. +// The user ID is taken from the context. +// +// The `orgIDColumn` specifies the table column to which this filter must be applied, +// and is typically the `resource_owner` column in ZITADEL. +// We use full identifiers in the query builder so this function should be +// called with something like `UserResourceOwnerCol.identifier()` for example. +func wherePermittedOrgs(ctx context.Context, query sq.SelectBuilder, orgIDColumn, permission string) sq.SelectBuilder { + userID := authz.GetCtxData(ctx).UserID + logging.WithFields("permission_check_v2_flag", authz.GetFeatures(ctx).PermissionCheckV2, "org_id_column", orgIDColumn, "permission", permission, "user_id", userID).Debug("permitted orgs check used") + return query.Where( + fmt.Sprintf(wherePermittedOrgsClause, orgIDColumn), + authz.GetInstance(ctx).InstanceID(), + userID, + permission, + ) +} diff --git a/internal/query/projection/instance_features.go b/internal/query/projection/instance_features.go index 2479203d09..2cd846bf2e 100644 --- a/internal/query/projection/instance_features.go +++ b/internal/query/projection/instance_features.go @@ -112,6 +112,10 @@ func (*instanceFeatureProjection) Reducers() []handler.AggregateReducer { Event: feature_v2.InstanceLoginVersion, Reduce: reduceInstanceSetFeature[*feature.LoginV2], }, + { + Event: feature_v2.InstancePermissionCheckV2, + Reduce: reduceInstanceSetFeature[bool], + }, { Event: instance.InstanceRemovedEventType, Reduce: reduceInstanceRemovedHelper(InstanceDomainInstanceIDCol), diff --git a/internal/query/projection/system_features.go b/internal/query/projection/system_features.go index 410234c27c..f6f0a36d56 100644 --- a/internal/query/projection/system_features.go +++ b/internal/query/projection/system_features.go @@ -92,6 +92,10 @@ func (*systemFeatureProjection) Reducers() []handler.AggregateReducer { Event: feature_v2.SystemLoginVersion, Reduce: reduceSystemSetFeature[*feature.LoginV2], }, + { + Event: feature_v2.SystemPermissionCheckV2, + Reduce: reduceSystemSetFeature[bool], + }, }, }} } diff --git a/internal/query/system_features.go b/internal/query/system_features.go index e696f6bf6f..31ad402d12 100644 --- a/internal/query/system_features.go +++ b/internal/query/system_features.go @@ -31,6 +31,7 @@ type SystemFeatures struct { DisableUserTokenEvent FeatureSource[bool] EnableBackChannelLogout FeatureSource[bool] LoginV2 FeatureSource[*feature.LoginV2] + PermissionCheckV2 FeatureSource[bool] } func (q *Queries) GetSystemFeatures(ctx context.Context) (_ *SystemFeatures, err error) { diff --git a/internal/query/system_features_model.go b/internal/query/system_features_model.go index f486e1ba4a..217154e3ed 100644 --- a/internal/query/system_features_model.go +++ b/internal/query/system_features_model.go @@ -66,6 +66,7 @@ func (m *SystemFeaturesReadModel) Query() *eventstore.SearchQueryBuilder { feature_v2.SystemDisableUserTokenEvent, feature_v2.SystemEnableBackChannelLogout, feature_v2.SystemLoginVersion, + feature_v2.SystemPermissionCheckV2, ). Builder().ResourceOwner(m.ResourceOwner) } @@ -105,6 +106,8 @@ func reduceSystemFeatureSet[T any](features *SystemFeatures, event *feature_v2.S features.EnableBackChannelLogout.set(level, event.Value) case feature.KeyLoginV2: features.LoginV2.set(level, event.Value) + case feature.KeyPermissionCheckV2: + features.PermissionCheckV2.set(level, event.Value) } return nil } diff --git a/internal/query/user.go b/internal/query/user.go index 415e50aae5..9f29ec77b3 100644 --- a/internal/query/user.go +++ b/internal/query/user.go @@ -605,24 +605,29 @@ func (q *Queries) GetNotifyUser(ctx context.Context, shouldTriggered bool, queri } func (q *Queries) SearchUsers(ctx context.Context, queries *UserSearchQueries, permissionCheck domain.PermissionCheck) (*Users, error) { - users, err := q.searchUsers(ctx, queries) + users, err := q.searchUsers(ctx, queries, permissionCheck != nil && authz.GetFeatures(ctx).PermissionCheckV2) if err != nil { return nil, err } - if permissionCheck != nil { + if permissionCheck != nil && !authz.GetFeatures(ctx).PermissionCheckV2 { usersCheckPermission(ctx, users, permissionCheck) } return users, nil } -func (q *Queries) searchUsers(ctx context.Context, queries *UserSearchQueries) (users *Users, err error) { +func (q *Queries) searchUsers(ctx context.Context, queries *UserSearchQueries, permissionCheckV2 bool) (users *Users, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() query, scan := prepareUsersQuery(ctx, q.client) - eq := sq.Eq{UserInstanceIDCol.identifier(): authz.GetInstance(ctx).InstanceID()} - stmt, args, err := queries.toQuery(query).Where(eq). - ToSql() + query = queries.toQuery(query).Where(sq.Eq{ + UserInstanceIDCol.identifier(): authz.GetInstance(ctx).InstanceID(), + }) + if permissionCheckV2 { + query = wherePermittedOrgs(ctx, query, UserResourceOwnerCol.identifier(), domain.PermissionUserRead) + } + + stmt, args, err := query.ToSql() if err != nil { return nil, zerrors.ThrowInternal(err, "QUERY-Dgbg2", "Errors.Query.SQLStatment") } diff --git a/internal/repository/feature/feature_v2/eventstore.go b/internal/repository/feature/feature_v2/eventstore.go index d4d2617aea..f5e033af1c 100644 --- a/internal/repository/feature/feature_v2/eventstore.go +++ b/internal/repository/feature/feature_v2/eventstore.go @@ -18,6 +18,7 @@ func init() { eventstore.RegisterFilterEventMapper(AggregateType, SystemDisableUserTokenEvent, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, SystemEnableBackChannelLogout, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, SystemLoginVersion, eventstore.GenericEventMapper[SetEvent[*feature.LoginV2]]) + eventstore.RegisterFilterEventMapper(AggregateType, SystemPermissionCheckV2, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceResetEventType, eventstore.GenericEventMapper[ResetEvent]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceLoginDefaultOrgEventType, eventstore.GenericEventMapper[SetEvent[bool]]) @@ -33,4 +34,5 @@ func init() { eventstore.RegisterFilterEventMapper(AggregateType, InstanceDisableUserTokenEvent, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceEnableBackChannelLogout, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceLoginVersion, eventstore.GenericEventMapper[SetEvent[*feature.LoginV2]]) + eventstore.RegisterFilterEventMapper(AggregateType, InstancePermissionCheckV2, eventstore.GenericEventMapper[SetEvent[bool]]) } diff --git a/internal/repository/feature/feature_v2/feature.go b/internal/repository/feature/feature_v2/feature.go index 0255203bdd..331a5143f9 100644 --- a/internal/repository/feature/feature_v2/feature.go +++ b/internal/repository/feature/feature_v2/feature.go @@ -23,6 +23,7 @@ var ( SystemDisableUserTokenEvent = setEventTypeFromFeature(feature.LevelSystem, feature.KeyDisableUserTokenEvent) SystemEnableBackChannelLogout = setEventTypeFromFeature(feature.LevelSystem, feature.KeyEnableBackChannelLogout) SystemLoginVersion = setEventTypeFromFeature(feature.LevelSystem, feature.KeyLoginV2) + SystemPermissionCheckV2 = setEventTypeFromFeature(feature.LevelSystem, feature.KeyPermissionCheckV2) InstanceResetEventType = resetEventTypeFromFeature(feature.LevelInstance) InstanceLoginDefaultOrgEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyLoginDefaultOrg) @@ -38,6 +39,7 @@ var ( InstanceDisableUserTokenEvent = setEventTypeFromFeature(feature.LevelInstance, feature.KeyDisableUserTokenEvent) InstanceEnableBackChannelLogout = setEventTypeFromFeature(feature.LevelInstance, feature.KeyEnableBackChannelLogout) InstanceLoginVersion = setEventTypeFromFeature(feature.LevelInstance, feature.KeyLoginV2) + InstancePermissionCheckV2 = setEventTypeFromFeature(feature.LevelInstance, feature.KeyPermissionCheckV2) ) const ( diff --git a/internal/repository/instance/member.go b/internal/repository/instance/member.go index 0518aab47f..161bdcdaec 100644 --- a/internal/repository/instance/member.go +++ b/internal/repository/instance/member.go @@ -7,17 +7,25 @@ import ( "github.com/zitadel/zitadel/internal/repository/member" ) -var ( +const ( MemberAddedEventType = instanceEventTypePrefix + member.AddedEventType MemberChangedEventType = instanceEventTypePrefix + member.ChangedEventType MemberRemovedEventType = instanceEventTypePrefix + member.RemovedEventType MemberCascadeRemovedEventType = instanceEventTypePrefix + member.CascadeRemovedEventType ) +const ( + fieldPrefix = "instance" +) + type MemberAddedEvent struct { member.MemberAddedEvent } +func (e *MemberAddedEvent) Fields() []*eventstore.FieldOperation { + return e.FieldOperations(fieldPrefix) +} + func NewMemberAddedEvent( ctx context.Context, aggregate *eventstore.Aggregate, @@ -51,6 +59,10 @@ type MemberChangedEvent struct { member.MemberChangedEvent } +func (e *MemberChangedEvent) Fields() []*eventstore.FieldOperation { + return e.FieldOperations(fieldPrefix) +} + func NewMemberChangedEvent( ctx context.Context, aggregate *eventstore.Aggregate, @@ -83,6 +95,10 @@ type MemberRemovedEvent struct { member.MemberRemovedEvent } +func (e *MemberRemovedEvent) Fields() []*eventstore.FieldOperation { + return e.FieldOperations(fieldPrefix) +} + func NewMemberRemovedEvent( ctx context.Context, aggregate *eventstore.Aggregate, @@ -113,6 +129,10 @@ type MemberCascadeRemovedEvent struct { member.MemberCascadeRemovedEvent } +func (e *MemberCascadeRemovedEvent) Fields() []*eventstore.FieldOperation { + return e.FieldOperations(fieldPrefix) +} + func NewMemberCascadeRemovedEvent( ctx context.Context, aggregate *eventstore.Aggregate, diff --git a/internal/repository/member/events.go b/internal/repository/member/events.go index 0c98b46a41..5d0a28c243 100644 --- a/internal/repository/member/events.go +++ b/internal/repository/member/events.go @@ -7,6 +7,7 @@ import ( "github.com/zitadel/zitadel/internal/zerrors" ) +// Event types const ( UniqueMember = "member" AddedEventType = "member.added" @@ -15,6 +16,13 @@ const ( CascadeRemovedEventType = "member.cascade.removed" ) +// Field table and unique types +const ( + memberRoleTypeSuffix string = "_member_role" + MemberRoleRevision uint8 = 1 + roleSearchFieldSuffix string = "_role" +) + func NewAddMemberUniqueConstraint(aggregateID, userID string) *eventstore.UniqueConstraint { return eventstore.NewAddEventUniqueConstraint( UniqueMember, @@ -44,6 +52,32 @@ func (e *MemberAddedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { return []*eventstore.UniqueConstraint{NewAddMemberUniqueConstraint(e.Aggregate().ID, e.UserID)} } +func (e *MemberAddedEvent) FieldOperations(prefix string) []*eventstore.FieldOperation { + ops := make([]*eventstore.FieldOperation, len(e.Roles)) + for i, role := range e.Roles { + ops[i] = eventstore.SetField( + e.Aggregate(), + memberSearchObject(prefix, e.UserID), + prefix+roleSearchFieldSuffix, + &eventstore.Value{ + Value: role, + MustBeUnique: false, + ShouldIndex: true, + }, + + eventstore.FieldTypeInstanceID, + eventstore.FieldTypeResourceOwner, + eventstore.FieldTypeAggregateType, + eventstore.FieldTypeAggregateID, + eventstore.FieldTypeObjectType, + eventstore.FieldTypeObjectID, + eventstore.FieldTypeFieldName, + eventstore.FieldTypeValue, + ) + } + return ops +} + func NewMemberAddedEvent( base *eventstore.BaseEvent, userID string, @@ -85,6 +119,38 @@ func (e *MemberChangedEvent) UniqueConstraints() []*eventstore.UniqueConstraint return nil } +// FieldOperations removes the existing membership role fields first and sets the new roles after. +func (e *MemberChangedEvent) FieldOperations(prefix string) []*eventstore.FieldOperation { + ops := make([]*eventstore.FieldOperation, len(e.Roles)+1) + ops[0] = eventstore.RemoveSearchFieldsByAggregateAndObject( + e.Aggregate(), + memberSearchObject(prefix, e.UserID), + ) + + for i, role := range e.Roles { + ops[i+1] = eventstore.SetField( + e.Aggregate(), + memberSearchObject(prefix, e.UserID), + prefix+roleSearchFieldSuffix, + &eventstore.Value{ + Value: role, + MustBeUnique: false, + ShouldIndex: true, + }, + + eventstore.FieldTypeInstanceID, + eventstore.FieldTypeResourceOwner, + eventstore.FieldTypeAggregateType, + eventstore.FieldTypeAggregateID, + eventstore.FieldTypeObjectType, + eventstore.FieldTypeObjectID, + eventstore.FieldTypeFieldName, + eventstore.FieldTypeValue, + ) + } + return ops +} + func NewMemberChangedEvent( base *eventstore.BaseEvent, userID string, @@ -124,6 +190,15 @@ func (e *MemberRemovedEvent) UniqueConstraints() []*eventstore.UniqueConstraint return []*eventstore.UniqueConstraint{NewRemoveMemberUniqueConstraint(e.Aggregate().ID, e.UserID)} } +func (e *MemberRemovedEvent) FieldOperations(prefix string) []*eventstore.FieldOperation { + return []*eventstore.FieldOperation{ + eventstore.RemoveSearchFieldsByAggregateAndObject( + e.Aggregate(), + memberSearchObject(prefix, e.UserID), + ), + } +} + func NewRemovedEvent( base *eventstore.BaseEvent, userID string, @@ -162,6 +237,15 @@ func (e *MemberCascadeRemovedEvent) UniqueConstraints() []*eventstore.UniqueCons return []*eventstore.UniqueConstraint{NewRemoveMemberUniqueConstraint(e.Aggregate().ID, e.UserID)} } +func (e *MemberCascadeRemovedEvent) FieldOperations(prefix string) []*eventstore.FieldOperation { + return []*eventstore.FieldOperation{ + eventstore.RemoveSearchFieldsByAggregateAndObject( + e.Aggregate(), + memberSearchObject(prefix, e.UserID), + ), + } +} + func NewCascadeRemovedEvent( base *eventstore.BaseEvent, userID string, @@ -185,3 +269,11 @@ func CascadeRemovedEventMapper(event eventstore.Event) (eventstore.Event, error) return e, nil } + +func memberSearchObject(prefix, userID string) eventstore.Object { + return eventstore.Object{ + Type: prefix + memberRoleTypeSuffix, + ID: userID, + Revision: MemberRoleRevision, + } +} diff --git a/internal/repository/org/member.go b/internal/repository/org/member.go index 81a4d5850f..5068a274b8 100644 --- a/internal/repository/org/member.go +++ b/internal/repository/org/member.go @@ -7,17 +7,25 @@ import ( "github.com/zitadel/zitadel/internal/repository/member" ) -var ( +const ( MemberAddedEventType = orgEventTypePrefix + member.AddedEventType MemberChangedEventType = orgEventTypePrefix + member.ChangedEventType MemberRemovedEventType = orgEventTypePrefix + member.RemovedEventType MemberCascadeRemovedEventType = orgEventTypePrefix + member.CascadeRemovedEventType ) +const ( + fieldPrefix = "org" +) + type MemberAddedEvent struct { member.MemberAddedEvent } +func (e *MemberAddedEvent) Fields() []*eventstore.FieldOperation { + return e.FieldOperations(fieldPrefix) +} + func NewMemberAddedEvent( ctx context.Context, aggregate *eventstore.Aggregate, @@ -50,6 +58,10 @@ type MemberChangedEvent struct { member.MemberChangedEvent } +func (e *MemberChangedEvent) Fields() []*eventstore.FieldOperation { + return e.FieldOperations(fieldPrefix) +} + func NewMemberChangedEvent( ctx context.Context, aggregate *eventstore.Aggregate, @@ -83,6 +95,10 @@ type MemberRemovedEvent struct { member.MemberRemovedEvent } +func (e *MemberRemovedEvent) Fields() []*eventstore.FieldOperation { + return e.FieldOperations(fieldPrefix) +} + func NewMemberRemovedEvent( ctx context.Context, aggregate *eventstore.Aggregate, @@ -113,6 +129,10 @@ type MemberCascadeRemovedEvent struct { member.MemberCascadeRemovedEvent } +func (e *MemberCascadeRemovedEvent) Fields() []*eventstore.FieldOperation { + return e.FieldOperations(fieldPrefix) +} + func NewMemberCascadeRemovedEvent( ctx context.Context, aggregate *eventstore.Aggregate, diff --git a/internal/repository/permission/aggregate.go b/internal/repository/permission/aggregate.go new file mode 100644 index 0000000000..a0ac199102 --- /dev/null +++ b/internal/repository/permission/aggregate.go @@ -0,0 +1,22 @@ +package permission + +import "github.com/zitadel/zitadel/internal/eventstore" + +const ( + AggregateType eventstore.AggregateType = "permission" + AggregateVersion eventstore.Version = "v1" +) + +func NewAggregate(aggregateID string) *eventstore.Aggregate { + var instanceID string + if aggregateID != "SYSTEM" { + instanceID = aggregateID + } + return &eventstore.Aggregate{ + ID: aggregateID, + Type: AggregateType, + ResourceOwner: aggregateID, + InstanceID: instanceID, + Version: AggregateVersion, + } +} diff --git a/internal/repository/permission/permission.go b/internal/repository/permission/permission.go new file mode 100644 index 0000000000..a02a4dca0a --- /dev/null +++ b/internal/repository/permission/permission.go @@ -0,0 +1,114 @@ +package permission + +import ( + "context" + + "github.com/zitadel/zitadel/internal/eventstore" +) + +// Event types +const ( + permissionEventPrefix eventstore.EventType = "permission." + AddedType = permissionEventPrefix + "added" + RemovedType = permissionEventPrefix + "removed" +) + +// Field table and unique types +const ( + RolePermissionType string = "role_permission" + RolePermissionRevision uint8 = 1 + PermissionSearchField string = "permission" +) + +type AddedEvent struct { + *eventstore.BaseEvent `json:"-"` + Role string `json:"role"` + Permission string `json:"permission"` +} + +func (e *AddedEvent) Payload() interface{} { + return e +} + +func (e *AddedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func (e *AddedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event +} + +func (e *AddedEvent) Fields() []*eventstore.FieldOperation { + return []*eventstore.FieldOperation{ + eventstore.SetField( + e.Aggregate(), + roleSearchObject(e.Role), + PermissionSearchField, + &eventstore.Value{ + Value: e.Permission, + MustBeUnique: false, + ShouldIndex: true, + }, + + eventstore.FieldTypeInstanceID, + eventstore.FieldTypeResourceOwner, + eventstore.FieldTypeAggregateType, + eventstore.FieldTypeAggregateID, + eventstore.FieldTypeObjectType, + eventstore.FieldTypeObjectID, + eventstore.FieldTypeFieldName, + eventstore.FieldTypeValue, + ), + } +} + +func NewAddedEvent(ctx context.Context, aggregate *eventstore.Aggregate, role, permission string) *AddedEvent { + return &AddedEvent{ + BaseEvent: eventstore.NewBaseEventForPush(ctx, aggregate, AddedType), + Role: role, + Permission: permission, + } +} + +type RemovedEvent struct { + *eventstore.BaseEvent `json:"-"` + Role string `json:"role"` + Permission string `json:"permission"` +} + +func (e *RemovedEvent) Payload() interface{} { + return e +} + +func (e *RemovedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func (e *RemovedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event +} + +func (e *RemovedEvent) Fields() []*eventstore.FieldOperation { + return []*eventstore.FieldOperation{ + eventstore.RemoveSearchFieldsByAggregateAndObject( + e.Aggregate(), + roleSearchObject(e.Role), + ), + } +} + +func NewRemovedEvent(ctx context.Context, aggregate *eventstore.Aggregate, role, permission string) *RemovedEvent { + return &RemovedEvent{ + BaseEvent: eventstore.NewBaseEventForPush(ctx, aggregate, AddedType), + Role: role, + Permission: permission, + } +} + +func roleSearchObject(role string) eventstore.Object { + return eventstore.Object{ + Type: RolePermissionType, + ID: role, + Revision: RolePermissionRevision, + } +} diff --git a/internal/repository/project/member.go b/internal/repository/project/member.go index d2928bfdc2..d04709b5fa 100644 --- a/internal/repository/project/member.go +++ b/internal/repository/project/member.go @@ -14,10 +14,18 @@ var ( MemberCascadeRemovedType = projectEventTypePrefix + member.CascadeRemovedEventType ) +const ( + fieldPrefix = "project" +) + type MemberAddedEvent struct { member.MemberAddedEvent } +func (e *MemberAddedEvent) Fields() []*eventstore.FieldOperation { + return e.FieldOperations(fieldPrefix) +} + func NewProjectMemberAddedEvent( ctx context.Context, aggregate *eventstore.Aggregate, @@ -50,6 +58,10 @@ type MemberChangedEvent struct { member.MemberChangedEvent } +func (e *MemberChangedEvent) Fields() []*eventstore.FieldOperation { + return e.FieldOperations(fieldPrefix) +} + func NewProjectMemberChangedEvent( ctx context.Context, aggregate *eventstore.Aggregate, @@ -83,6 +95,10 @@ type MemberRemovedEvent struct { member.MemberRemovedEvent } +func (e *MemberRemovedEvent) Fields() []*eventstore.FieldOperation { + return e.FieldOperations(fieldPrefix) +} + func NewProjectMemberRemovedEvent( ctx context.Context, aggregate *eventstore.Aggregate, @@ -114,6 +130,10 @@ type MemberCascadeRemovedEvent struct { member.MemberCascadeRemovedEvent } +func (e *MemberCascadeRemovedEvent) Fields() []*eventstore.FieldOperation { + return e.FieldOperations(fieldPrefix) +} + func NewProjectMemberCascadeRemovedEvent( ctx context.Context, aggregate *eventstore.Aggregate, diff --git a/proto/zitadel/feature/v2/instance.proto b/proto/zitadel/feature/v2/instance.proto index 385ce5a4d0..3d2280fc0c 100644 --- a/proto/zitadel/feature/v2/instance.proto +++ b/proto/zitadel/feature/v2/instance.proto @@ -99,6 +99,13 @@ message SetInstanceFeaturesRequest{ description: "Specify the login UI for all users and applications regardless of their preference."; } ]; + + optional bool permission_check_v2 = 14 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "true"; + description: "Enable a newer, more performant, permission check used for v2 and v3 resource based APIs."; + } + ]; } message SetInstanceFeaturesResponse { @@ -212,4 +219,10 @@ message GetInstanceFeaturesResponse { description: "If the flag is set, all users will be redirected to the login V2 regardless of the application's preference."; } ]; + + FeatureFlag permission_check_v2 = 15 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Enable a newer, more performant, permission check used for v2 and v3 resource based APIs."; + } + ]; } diff --git a/proto/zitadel/feature/v2/system.proto b/proto/zitadel/feature/v2/system.proto index cac8fe774f..c734905fb2 100644 --- a/proto/zitadel/feature/v2/system.proto +++ b/proto/zitadel/feature/v2/system.proto @@ -88,6 +88,13 @@ message SetSystemFeaturesRequest{ description: "Specify the login UI for all users and applications regardless of their preference."; } ]; + + optional bool permission_check_v2 = 12 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "true"; + description: "Enable a newer, more performant, permission check used for v2 and v3 resource based APIs."; + } + ]; } message SetSystemFeaturesResponse { @@ -180,4 +187,10 @@ message GetSystemFeaturesResponse { description: "If the flag is set, all users will be redirected to the login V2 regardless of the application's preference."; } ]; + + FeatureFlag permission_check_v2 = 13 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Enable a newer, more performant, permission check used for v2 and v3 resource based APIs."; + } + ]; }