diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index b0564d0b2..90c9bd74c 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -334,11 +334,6 @@ OIDC: Path: /oauth/v2/device_authorization # ZITADEL_OIDC_CUSTOMENDPOINTS_DEVICEAUTH_PATH DefaultLoginURLV2: "/login?authRequest=" # ZITADEL_OIDC_DEFAULTLOGINURLV2 DefaultLogoutURLV2: "/logout?post_logout_redirect=" # ZITADEL_OIDC_DEFAULTLOGOUTURLV2 - Features: - # Wheter projection triggers are used in the new Introspection implementation. - TriggerIntrospectionProjections: false - # Allows fallback to the Legacy Introspection implementation - LegacyIntrospection: false PublicKeyCacheMaxAge: 24h # ZITADEL_OIDC_PUBLICKEYCACHEMAXAGE SAML: @@ -431,7 +426,6 @@ SystemAPIUsers: # Configure the SystemAPIUsers by environment variable using JSON notation: # ZITADEL_SYSTEMAPIUSERS='{"systemuser":{"Path":"/path/to/superuser/key.pem"},"systemuser2":{"KeyData":""}}' -#TODO: remove as soon as possible SystemDefaults: SecretGenerators: PasswordSaltCost: 14 # ZITADEL_SYSTEMDEFAULTS_SECRETGENERATORS_PASSWORDSALTCOST @@ -833,8 +827,13 @@ DefaultInstance: Greeting: Hello {{.DisplayName}}, Text: The password of your user has changed. If this change was not done by you, please be advised to immediately reset your password. ButtonText: Login + + # Once a feature is set on the instance (true or false), system level feature settings + # will be ignored until instance level features are reset. Features: - - FeatureLoginDefaultOrg: true + LoginDefaultOrg: true # ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINDEFAULTORG + # TriggerIntrospectionProjections: false # ZITADEL_DEFAULTINSTANCE_FEATURES_TRIGGERINTROSPECTIONPROJECTIONS + # LegacyIntrospection: false # ZITADEL_DEFAULTINSTANCE_FEATURES_LEGACYINTROSPECTION 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. @@ -910,7 +909,9 @@ InternalAuthZ: - "system.debug.read" - "system.debug.write" - "system.debug.delete" + - "system.feature.read" - "system.feature.write" + - "system.feature.delete" - "system.limits.write" - "system.limits.delete" - "system.quota.write" @@ -921,6 +922,7 @@ InternalAuthZ: - "system.instance.read" - "system.domain.read" - "system.debug.read" + - "system.feature.read" - "system.iam.member.read" - Role: "IAM_OWNER" Permissions: @@ -941,7 +943,9 @@ InternalAuthZ: - "iam.flow.read" - "iam.flow.write" - "iam.flow.delete" + - "iam.feature.read" - "iam.feature.write" + - "iam.feature.delete" - "iam.restrictions.read" - "iam.restrictions.write" - "org.read" @@ -961,6 +965,9 @@ InternalAuthZ: - "org.flow.read" - "org.flow.write" - "org.flow.delete" + - "org.feature.read" + - "org.feature.write" + - "org.feature.delete" - "user.read" - "user.global.read" - "user.write" @@ -971,6 +978,9 @@ InternalAuthZ: - "user.membership.read" - "user.credential.write" - "user.passkey.write" + - "user.feature.read" + - "user.feature.write" + - "user.feature.delete" - "policy.read" - "policy.write" - "policy.delete" @@ -1010,15 +1020,18 @@ InternalAuthZ: - "iam.action.read" - "iam.flow.read" - "iam.restrictions.read" + - "iam.feature.read" - "org.read" - "org.member.read" - "org.idp.read" - "org.action.read" - "org.flow.read" + - "org.feature.read" - "user.read" - "user.global.read" - "user.grant.read" - "user.membership.read" + - "user.feature.read" - "policy.read" - "project.read" - "project.member.read" @@ -1047,6 +1060,9 @@ InternalAuthZ: - "org.flow.read" - "org.flow.write" - "org.flow.delete" + - "org.feature.read" + - "org.feature.write" + - "org.feature.delete" - "user.read" - "user.global.read" - "user.write" @@ -1057,6 +1073,9 @@ InternalAuthZ: - "user.membership.read" - "user.credential.write" - "user.passkey.write" + - "user.feature.read" + - "user.feature.write" + - "user.feature.delete" - "policy.read" - "policy.write" - "policy.delete" @@ -1095,6 +1114,9 @@ InternalAuthZ: - "user.grant.delete" - "user.membership.read" - "user.passkey.write" + - "user.feature.read" + - "user.feature.write" + - "user.feature.delete" - "project.read" - "project.member.read" - "project.role.read" @@ -1122,6 +1144,9 @@ InternalAuthZ: - "org.flow.read" - "org.flow.write" - "org.flow.delete" + - "org.feature.read" + - "org.feature.write" + - "org.feature.delete" - "user.read" - "user.global.read" - "user.write" @@ -1132,6 +1157,9 @@ InternalAuthZ: - "user.membership.read" - "user.credential.write" - "user.passkey.write" + - "user.feature.read" + - "user.feature.write" + - "user.feature.delete" - "policy.read" - "policy.write" - "policy.delete" @@ -1165,6 +1193,9 @@ InternalAuthZ: - "user.grant.write" - "user.grant.delete" - "user.membership.read" + - "user.feature.read" + - "user.feature.write" + - "user.feature.delete" - "policy.read" - "project.read" - "project.role.read" @@ -1176,10 +1207,12 @@ InternalAuthZ: - "org.idp.read" - "org.action.read" - "org.flow.read" + - "org.feature.read" - "user.read" - "user.global.read" - "user.grant.read" - "user.membership.read" + - "user.feature.read" - "policy.read" - "project.read" - "project.member.read" @@ -1196,6 +1229,9 @@ InternalAuthZ: - "org.idp.read" - "org.idp.write" - "org.idp.delete" + - "org.feature.read" + - "org.feature.write" + - "org.feature.delete" - "policy.read" - "policy.write" - "policy.delete" diff --git a/cmd/ready/config.go b/cmd/ready/config.go index 488dbe27d..aaa7e2d7e 100644 --- a/cmd/ready/config.go +++ b/cmd/ready/config.go @@ -10,7 +10,6 @@ import ( internal_authz "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/config/hook" "github.com/zitadel/zitadel/internal/config/network" - "github.com/zitadel/zitadel/internal/domain" ) type Config struct { @@ -27,7 +26,6 @@ func MustNewConfig(v *viper.Viper) *Config { mapstructure.StringToTimeDurationHookFunc(), mapstructure.StringToTimeHookFunc(time.RFC3339), mapstructure.StringToSliceHookFunc(","), - hook.EnumHookFunc(domain.FeatureString), hook.EnumHookFunc(internal_authz.MemberTypeString), )), ) diff --git a/cmd/setup/03.go b/cmd/setup/03.go index af2ab9c49..0d8c98868 100644 --- a/cmd/setup/03.go +++ b/cmd/setup/03.go @@ -24,7 +24,7 @@ type FirstInstance struct { Org command.InstanceOrgSetup MachineKeyPath string PatPath string - Features map[domain.Feature]any + Features *command.InstanceFeatures instanceSetup command.InstanceSetup userEncryptionKey *crypto.KeyConfig diff --git a/cmd/setup/config.go b/cmd/setup/config.go index 4213cd59e..10ec0328c 100644 --- a/cmd/setup/config.go +++ b/cmd/setup/config.go @@ -19,7 +19,6 @@ import ( "github.com/zitadel/zitadel/internal/config/hook" "github.com/zitadel/zitadel/internal/config/systemdefaults" "github.com/zitadel/zitadel/internal/database" - "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/id" "github.com/zitadel/zitadel/internal/notification/handlers" @@ -67,7 +66,6 @@ func MustNewConfig(v *viper.Viper) *Config { mapstructure.StringToTimeHookFunc(time.RFC3339), mapstructure.StringToSliceHookFunc(","), database.DecodeHook, - hook.EnumHookFunc(domain.FeatureString), hook.EnumHookFunc(authz.MemberTypeString), actions.HTTPConfigDecodeHook, hooks.MapTypeStringDecode[string, *authz.SystemAPIUser], @@ -127,7 +125,6 @@ func MustNewSteps(v *viper.Viper) *Steps { mapstructure.StringToTimeDurationHookFunc(), mapstructure.StringToTimeHookFunc(time.RFC3339), mapstructure.StringToSliceHookFunc(","), - hook.EnumHookFunc(domain.FeatureString), )), ) logging.OnError(err).Fatal("unable to read steps") diff --git a/cmd/start/config.go b/cmd/start/config.go index 70c970de4..5a4d3516a 100644 --- a/cmd/start/config.go +++ b/cmd/start/config.go @@ -97,7 +97,6 @@ func MustNewConfig(v *viper.Viper) *Config { mapstructure.StringToSliceHookFunc(","), database.DecodeHook, actions.HTTPConfigDecodeHook, - hook.EnumHookFunc(domain.FeatureString), hook.EnumHookFunc(internal_authz.MemberTypeString), )), ) diff --git a/cmd/start/config_test.go b/cmd/start/config_test.go index 0e8f105a2..a7802cf58 100644 --- a/cmd/start/config_test.go +++ b/cmd/start/config_test.go @@ -8,12 +8,14 @@ import ( "strings" "testing" + "github.com/muhlemmer/gu" "github.com/spf13/viper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/zitadel/zitadel/internal/actions" "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/domain" ) @@ -70,7 +72,9 @@ Log: args: args{yaml: ` DefaultInstance: Features: - - FeatureLoginDefaultOrg: true + LoginDefaultOrg: true + LegacyIntrospection: true + TriggerIntrospectionProjections: true Log: Level: info Actions: @@ -78,25 +82,10 @@ Actions: DenyList: [] `}, want: func(t *testing.T, config *Config) { - assert.Equal(t, config.DefaultInstance.Features, map[domain.Feature]any{ - domain.FeatureLoginDefaultOrg: true, - }) - }, - }, { - name: "features string ok", - args: args{yaml: ` -DefaultInstance: - Features: > - [{"featureLoginDefaultOrg": true}] -Log: - Level: info -Actions: - HTTP: - DenyList: [] -`}, - want: func(t *testing.T, config *Config) { - assert.Equal(t, config.DefaultInstance.Features, map[domain.Feature]any{ - domain.FeatureLoginDefaultOrg: true, + assert.Equal(t, config.DefaultInstance.Features, &command.InstanceFeatures{ + LoginDefaultOrg: gu.Ptr(true), + LegacyIntrospection: gu.Ptr(true), + TriggerIntrospectionProjections: gu.Ptr(true), }) }, }, { diff --git a/cmd/start/start.go b/cmd/start/start.go index 0dc91d52f..e671aadf1 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -29,7 +29,6 @@ import ( "github.com/zitadel/zitadel/cmd/encryption" "github.com/zitadel/zitadel/cmd/key" cmd_tls "github.com/zitadel/zitadel/cmd/tls" - "github.com/zitadel/zitadel/feature" "github.com/zitadel/zitadel/internal/actions" admin_es "github.com/zitadel/zitadel/internal/admin/repository/eventsourcing" "github.com/zitadel/zitadel/internal/api" @@ -38,6 +37,7 @@ import ( "github.com/zitadel/zitadel/internal/api/grpc/admin" "github.com/zitadel/zitadel/internal/api/grpc/auth" execution_v3_alpha "github.com/zitadel/zitadel/internal/api/grpc/execution/v3alpha" + "github.com/zitadel/zitadel/internal/api/grpc/feature/v2" "github.com/zitadel/zitadel/internal/api/grpc/management" oidc_v2 "github.com/zitadel/zitadel/internal/api/grpc/oidc/v2" "github.com/zitadel/zitadel/internal/api/grpc/org/v2" @@ -404,6 +404,9 @@ func startAPIs( if err := apis.RegisterService(ctx, org.CreateServer(commands, queries, permissionCheck)); err != nil { return nil, err } + if err := apis.RegisterService(ctx, feature.CreateServer(commands, queries)); err != nil { + return nil, err + } if err := apis.RegisterService(ctx, execution_v3_alpha.CreateServer(commands, queries, domain.AllFunctions, apis.ListGrpcMethods, apis.ListGrpcServices)); err != nil { return nil, err } @@ -469,7 +472,6 @@ func startAPIs( keys.User, keys.IDPConfig, keys.CSRFCookieKey, - feature.NewCheck(eventstore), ) if err != nil { return nil, fmt.Errorf("unable to start login: %w", err) diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index 35b434133..692a480f4 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -311,6 +311,13 @@ module.exports = { groupPathsBy: "tag", }, }, + feature_v2: { + specPath: ".artifacts/openapi/zitadel/feature/v2beta/feature_service.swagger.json", + outputDir: "docs/apis/resources/feature_service_v2", + sidebarOptions: { + groupPathsBy: "tag", + }, + }, }, }, ], diff --git a/docs/sidebars.js b/docs/sidebars.js index 0ce398645..7593c2a68 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -694,6 +694,20 @@ module.exports = { }, items: require("./docs/apis/resources/settings_service/sidebar.js"), }, + { + type: "category", + label: "Feature Lifecycle (Beta)", + link: { + type: "generated-index", + title: "Feature Service API (Beta)", + slug: "/apis/resources/feature_service", + description: + "This API is intended to manage features for ZITADEL. Feature settings that are available on multiple \"levels\", such as instance and organization. The higher level instance acts as a default for the lower level. When a feature is set on multiple levels, the lower level takes precedence. Features can be experimental where ZITADEL will assume a sane default, such as disabled. When over time confidence in such a feature grows, ZITADEL can default to enabling the feature. As a final step we might choose to always enable a feature and remove the setting from this API, reserving the proto field number. Such removal is not considered a breaking change. Setting a removed field will effectively result in a no-op.\n" + + "\n" + + "This project is in beta state. It can AND will continue breaking until a stable version is released.", + }, + items: require("./docs/apis/resources/feature_service_v2/sidebar.js"), + }, ] }, { diff --git a/feature/check.go b/feature/check.go deleted file mode 100644 index eb66ce119..000000000 --- a/feature/check.go +++ /dev/null @@ -1,45 +0,0 @@ -package feature - -import ( - "context" - - "github.com/zitadel/zitadel/internal/api/authz" - "github.com/zitadel/zitadel/internal/command" - "github.com/zitadel/zitadel/internal/command/preparation" - "github.com/zitadel/zitadel/internal/domain" - "github.com/zitadel/zitadel/internal/eventstore" - "github.com/zitadel/zitadel/internal/repository/feature" -) - -type Checker interface { - CheckInstanceBooleanFeature(ctx context.Context, f domain.Feature) (feature.Boolean, error) -} - -func NewCheck(eventstore *eventstore.Eventstore) *Check { - return &Check{eventstore: eventstore} -} - -type Check struct { - eventstore *eventstore.Eventstore -} - -func (c *Check) CheckInstanceBooleanFeature(ctx context.Context, f domain.Feature) (feature.Boolean, error) { - return getInstanceFeature[feature.Boolean](ctx, f, c.eventstore.Filter) -} - -func getInstanceFeature[T feature.SetEventType](ctx context.Context, f domain.Feature, filter preparation.FilterToQueryReducer) (T, error) { - instanceID := authz.GetInstance(ctx).InstanceID() - writeModel, err := command.NewInstanceFeatureWriteModel[T](instanceID, f) - if err != nil { - return writeModel.Value, err - } - events, err := filter(ctx, writeModel.Query()) - if err != nil { - return writeModel.Value, err - } - writeModel.AppendEvents(events...) - if err = writeModel.Reduce(); err != nil { - return writeModel.Value, err - } - return writeModel.Value, nil -} diff --git a/internal/api/authz/instance.go b/internal/api/authz/instance.go index 8c86a02fc..3dfd88e89 100644 --- a/internal/api/authz/instance.go +++ b/internal/api/authz/instance.go @@ -5,6 +5,8 @@ import ( "time" "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/feature" ) var ( @@ -23,6 +25,7 @@ type Instance interface { SecurityPolicyAllowedOrigins() []string Block() *bool AuditLogRetention() *time.Duration + Features() feature.Features } type InstanceVerifier interface { @@ -37,6 +40,7 @@ type instance struct { appID string clientID string orgID string + features feature.Features } func (i *instance) Block() *bool { @@ -83,6 +87,10 @@ func (i *instance) SecurityPolicyAllowedOrigins() []string { return nil } +func (i *instance) Features() feature.Features { + return i.features +} + func GetInstance(ctx context.Context) Instance { instance, ok := ctx.Value(instanceKey).(Instance) if !ok { @@ -91,6 +99,10 @@ func GetInstance(ctx context.Context) Instance { return instance } +func GetFeatures(ctx context.Context) feature.Features { + return GetInstance(ctx).Features() +} + func WithInstance(ctx context.Context, instance Instance) context.Context { return context.WithValue(ctx, instanceKey, instance) } @@ -120,3 +132,12 @@ func WithConsole(ctx context.Context, projectID, appID string) context.Context { //i.clientID = clientID return context.WithValue(ctx, instanceKey, i) } + +func WithFeatures(ctx context.Context, f feature.Features) context.Context { + i, ok := ctx.Value(instanceKey).(*instance) + if !ok { + i = new(instance) + } + i.features = f + return context.WithValue(ctx, instanceKey, i) +} diff --git a/internal/api/authz/instance_test.go b/internal/api/authz/instance_test.go index cb00d349a..7057a1829 100644 --- a/internal/api/authz/instance_test.go +++ b/internal/api/authz/instance_test.go @@ -7,6 +7,8 @@ import ( "github.com/stretchr/testify/assert" "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/feature" ) func Test_Instance(t *testing.T) { @@ -17,6 +19,7 @@ func Test_Instance(t *testing.T) { instanceID string projectID string consoleID string + features feature.Features } tests := []struct { name string @@ -56,6 +59,19 @@ func Test_Instance(t *testing.T) { consoleID: "consoleID", }, }, + { + "WithFeatures", + args{ + WithFeatures(context.Background(), feature.Features{ + LoginDefaultOrg: true, + }), + }, + res{ + features: feature.Features{ + LoginDefaultOrg: true, + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -63,6 +79,7 @@ func Test_Instance(t *testing.T) { assert.Equal(t, tt.res.instanceID, got.InstanceID()) assert.Equal(t, tt.res.projectID, got.ProjectID()) assert.Equal(t, tt.res.consoleID, got.ConsoleClientID()) + assert.Equal(t, tt.res.features, got.Features()) }) } } @@ -112,3 +129,7 @@ func (m *mockInstance) RequestedHost() string { func (m *mockInstance) SecurityPolicyAllowedOrigins() []string { return nil } + +func (m *mockInstance) Features() feature.Features { + return feature.Features{} +} diff --git a/internal/api/grpc/admin/feature.go b/internal/api/grpc/admin/feature.go index 2fe443d5e..c581ba9e9 100644 --- a/internal/api/grpc/admin/feature.go +++ b/internal/api/grpc/admin/feature.go @@ -3,13 +3,17 @@ package admin import ( "context" + "github.com/muhlemmer/gu" + object_pb "github.com/zitadel/zitadel/internal/api/grpc/object" - "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/command" admin_pb "github.com/zitadel/zitadel/pkg/grpc/admin" ) func (s *Server) ActivateFeatureLoginDefaultOrg(ctx context.Context, _ *admin_pb.ActivateFeatureLoginDefaultOrgRequest) (*admin_pb.ActivateFeatureLoginDefaultOrgResponse, error) { - details, err := s.command.SetBooleanInstanceFeature(ctx, domain.FeatureLoginDefaultOrg, true) + details, err := s.command.SetInstanceFeatures(ctx, &command.InstanceFeatures{ + LoginDefaultOrg: gu.Ptr(true), + }) if err != nil { return nil, err } diff --git a/internal/api/grpc/feature/v2/converter.go b/internal/api/grpc/feature/v2/converter.go new file mode 100644 index 000000000..46d6f33ad --- /dev/null +++ b/internal/api/grpc/feature/v2/converter.go @@ -0,0 +1,71 @@ +package feature + +import ( + "github.com/zitadel/zitadel/internal/api/grpc/object/v2" + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/feature" + "github.com/zitadel/zitadel/internal/query" + feature_pb "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta" +) + +func systemFeaturesToCommand(req *feature_pb.SetSystemFeaturesRequest) *command.SystemFeatures { + return &command.SystemFeatures{ + LoginDefaultOrg: req.LoginDefaultOrg, + TriggerIntrospectionProjections: req.OidcTriggerIntrospectionProjections, + LegacyIntrospection: req.OidcLegacyIntrospection, + } +} + +func systemFeaturesToPb(f *query.SystemFeatures) *feature_pb.GetSystemFeaturesResponse { + return &feature_pb.GetSystemFeaturesResponse{ + Details: object.DomainToDetailsPb(f.Details), + LoginDefaultOrg: featureSourceToFlagPb(&f.LoginDefaultOrg), + OidcTriggerIntrospectionProjections: featureSourceToFlagPb(&f.TriggerIntrospectionProjections), + OidcLegacyIntrospection: featureSourceToFlagPb(&f.LegacyIntrospection), + } +} + +func instanceFeaturesToCommand(req *feature_pb.SetInstanceFeaturesRequest) *command.InstanceFeatures { + return &command.InstanceFeatures{ + LoginDefaultOrg: req.LoginDefaultOrg, + TriggerIntrospectionProjections: req.OidcTriggerIntrospectionProjections, + LegacyIntrospection: req.OidcLegacyIntrospection, + } +} + +func instanceFeaturesToPb(f *query.InstanceFeatures) *feature_pb.GetInstanceFeaturesResponse { + return &feature_pb.GetInstanceFeaturesResponse{ + Details: object.DomainToDetailsPb(f.Details), + LoginDefaultOrg: featureSourceToFlagPb(&f.LoginDefaultOrg), + OidcTriggerIntrospectionProjections: featureSourceToFlagPb(&f.TriggerIntrospectionProjections), + OidcLegacyIntrospection: featureSourceToFlagPb(&f.LegacyIntrospection), + } +} + +func featureSourceToFlagPb(fs *query.FeatureSource[bool]) *feature_pb.FeatureFlag { + return &feature_pb.FeatureFlag{ + Enabled: fs.Value, + Source: featureLevelToSourcePb(fs.Level), + } +} + +func featureLevelToSourcePb(level feature.Level) feature_pb.Source { + switch level { + case feature.LevelUnspecified: + return feature_pb.Source_SOURCE_UNSPECIFIED + case feature.LevelSystem: + return feature_pb.Source_SOURCE_SYSTEM + case feature.LevelInstance: + return feature_pb.Source_SOURCE_INSTANCE + case feature.LevelOrg: + return feature_pb.Source_SOURCE_ORGANIZATION + case feature.LevelProject: + return feature_pb.Source_SOURCE_PROJECT + case feature.LevelApp: + return feature_pb.Source_SOURCE_APP + case feature.LevelUser: + return feature_pb.Source_SOURCE_USER + default: + return feature_pb.Source(level) + } +} diff --git a/internal/api/grpc/feature/v2/converter_test.go b/internal/api/grpc/feature/v2/converter_test.go new file mode 100644 index 000000000..43a1e9c78 --- /dev/null +++ b/internal/api/grpc/feature/v2/converter_test.go @@ -0,0 +1,188 @@ +package feature + +import ( + "testing" + "time" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/feature" + "github.com/zitadel/zitadel/internal/query" + feature_pb "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta" + object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" +) + +func Test_systemFeaturesToCommand(t *testing.T) { + arg := &feature_pb.SetSystemFeaturesRequest{ + LoginDefaultOrg: gu.Ptr(true), + OidcTriggerIntrospectionProjections: gu.Ptr(false), + OidcLegacyIntrospection: nil, + } + want := &command.SystemFeatures{ + LoginDefaultOrg: gu.Ptr(true), + TriggerIntrospectionProjections: gu.Ptr(false), + LegacyIntrospection: nil, + } + got := systemFeaturesToCommand(arg) + assert.Equal(t, want, got) +} + +func Test_systemFeaturesToPb(t *testing.T) { + arg := &query.SystemFeatures{ + Details: &domain.ObjectDetails{ + Sequence: 22, + EventDate: time.Unix(123, 0), + ResourceOwner: "SYSTEM", + }, + LoginDefaultOrg: query.FeatureSource[bool]{ + Level: feature.LevelSystem, + Value: true, + }, + TriggerIntrospectionProjections: query.FeatureSource[bool]{ + Level: feature.LevelUnspecified, + Value: false, + }, + LegacyIntrospection: query.FeatureSource[bool]{ + Level: feature.LevelSystem, + Value: true, + }, + } + want := &feature_pb.GetSystemFeaturesResponse{ + Details: &object.Details{ + Sequence: 22, + ChangeDate: ×tamppb.Timestamp{Seconds: 123}, + ResourceOwner: "SYSTEM", + }, + LoginDefaultOrg: &feature_pb.FeatureFlag{ + Enabled: true, + Source: feature_pb.Source_SOURCE_SYSTEM, + }, + OidcTriggerIntrospectionProjections: &feature_pb.FeatureFlag{ + Enabled: false, + Source: feature_pb.Source_SOURCE_UNSPECIFIED, + }, + OidcLegacyIntrospection: &feature_pb.FeatureFlag{ + Enabled: true, + Source: feature_pb.Source_SOURCE_SYSTEM, + }, + } + got := systemFeaturesToPb(arg) + assert.Equal(t, want, got) +} + +func Test_instanceFeaturesToCommand(t *testing.T) { + arg := &feature_pb.SetInstanceFeaturesRequest{ + LoginDefaultOrg: gu.Ptr(true), + OidcTriggerIntrospectionProjections: gu.Ptr(false), + OidcLegacyIntrospection: nil, + } + want := &command.InstanceFeatures{ + LoginDefaultOrg: gu.Ptr(true), + TriggerIntrospectionProjections: gu.Ptr(false), + LegacyIntrospection: nil, + } + got := instanceFeaturesToCommand(arg) + assert.Equal(t, want, got) +} + +func Test_instanceFeaturesToPb(t *testing.T) { + arg := &query.InstanceFeatures{ + Details: &domain.ObjectDetails{ + Sequence: 22, + EventDate: time.Unix(123, 0), + ResourceOwner: "instance1", + }, + LoginDefaultOrg: query.FeatureSource[bool]{ + Level: feature.LevelSystem, + Value: true, + }, + TriggerIntrospectionProjections: query.FeatureSource[bool]{ + Level: feature.LevelUnspecified, + Value: false, + }, + LegacyIntrospection: query.FeatureSource[bool]{ + Level: feature.LevelInstance, + Value: true, + }, + } + want := &feature_pb.GetInstanceFeaturesResponse{ + Details: &object.Details{ + Sequence: 22, + ChangeDate: ×tamppb.Timestamp{Seconds: 123}, + ResourceOwner: "instance1", + }, + LoginDefaultOrg: &feature_pb.FeatureFlag{ + Enabled: true, + Source: feature_pb.Source_SOURCE_SYSTEM, + }, + OidcTriggerIntrospectionProjections: &feature_pb.FeatureFlag{ + Enabled: false, + Source: feature_pb.Source_SOURCE_UNSPECIFIED, + }, + OidcLegacyIntrospection: &feature_pb.FeatureFlag{ + Enabled: true, + Source: feature_pb.Source_SOURCE_INSTANCE, + }, + } + got := instanceFeaturesToPb(arg) + assert.Equal(t, want, got) +} + +func Test_featureLevelToSourcePb(t *testing.T) { + tests := []struct { + name string + level feature.Level + want feature_pb.Source + }{ + { + name: "unspecified", + level: feature.LevelUnspecified, + want: feature_pb.Source_SOURCE_UNSPECIFIED, + }, + { + name: "system", + level: feature.LevelSystem, + want: feature_pb.Source_SOURCE_SYSTEM, + }, + { + name: "instance", + level: feature.LevelInstance, + want: feature_pb.Source_SOURCE_INSTANCE, + }, + { + name: "org", + level: feature.LevelOrg, + want: feature_pb.Source_SOURCE_ORGANIZATION, + }, + { + name: "project", + level: feature.LevelProject, + want: feature_pb.Source_SOURCE_PROJECT, + }, + { + name: "app", + level: feature.LevelApp, + want: feature_pb.Source_SOURCE_APP, + }, + { + name: "user", + level: feature.LevelUser, + want: feature_pb.Source_SOURCE_USER, + }, + { + name: "unknown", + level: 99, + want: 99, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := featureLevelToSourcePb(tt.level) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/api/grpc/feature/v2/feature.go b/internal/api/grpc/feature/v2/feature.go new file mode 100644 index 000000000..1e4200ccf --- /dev/null +++ b/internal/api/grpc/feature/v2/feature.go @@ -0,0 +1,86 @@ +package feature + +import ( + "context" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/zitadel/zitadel/internal/api/grpc/object/v2" + feature "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta" +) + +func (s *Server) SetSystemFeatures(ctx context.Context, req *feature.SetSystemFeaturesRequest) (_ *feature.SetSystemFeaturesResponse, err error) { + details, err := s.command.SetSystemFeatures(ctx, systemFeaturesToCommand(req)) + if err != nil { + return nil, err + } + return &feature.SetSystemFeaturesResponse{ + Details: object.DomainToDetailsPb(details), + }, nil +} + +func (s *Server) ResetSystemFeatures(ctx context.Context, req *feature.ResetSystemFeaturesRequest) (_ *feature.ResetSystemFeaturesResponse, err error) { + details, err := s.command.ResetSystemFeatures(ctx) + if err != nil { + return nil, err + } + return &feature.ResetSystemFeaturesResponse{ + Details: object.DomainToDetailsPb(details), + }, nil +} + +func (s *Server) GetSystemFeatures(ctx context.Context, req *feature.GetSystemFeaturesRequest) (_ *feature.GetSystemFeaturesResponse, err error) { + f, err := s.query.GetSystemFeatures(ctx) + if err != nil { + return nil, err + } + return systemFeaturesToPb(f), nil +} + +func (s *Server) SetInstanceFeatures(ctx context.Context, req *feature.SetInstanceFeaturesRequest) (_ *feature.SetInstanceFeaturesResponse, err error) { + details, err := s.command.SetInstanceFeatures(ctx, instanceFeaturesToCommand(req)) + if err != nil { + return nil, err + } + return &feature.SetInstanceFeaturesResponse{ + Details: object.DomainToDetailsPb(details), + }, nil +} + +func (s *Server) ResetInstanceFeatures(ctx context.Context, req *feature.ResetInstanceFeaturesRequest) (_ *feature.ResetInstanceFeaturesResponse, err error) { + details, err := s.command.ResetInstanceFeatures(ctx) + if err != nil { + return nil, err + } + return &feature.ResetInstanceFeaturesResponse{ + Details: object.DomainToDetailsPb(details), + }, nil +} + +func (s *Server) GetInstanceFeatures(ctx context.Context, req *feature.GetInstanceFeaturesRequest) (_ *feature.GetInstanceFeaturesResponse, err error) { + f, err := s.query.GetInstanceFeatures(ctx, req.GetInheritance()) + if err != nil { + return nil, err + } + return instanceFeaturesToPb(f), nil +} + +func (s *Server) SetOrganizationFeatures(ctx context.Context, req *feature.SetOrganizationFeaturesRequest) (_ *feature.SetOrganizationFeaturesResponse, err error) { + return nil, status.Errorf(codes.Unimplemented, "method SetOrganizationFeatures not implemented") +} +func (s *Server) ResetOrganizationFeatures(ctx context.Context, req *feature.ResetOrganizationFeaturesRequest) (_ *feature.ResetOrganizationFeaturesResponse, err error) { + return nil, status.Errorf(codes.Unimplemented, "method ResetOrganizationFeatures not implemented") +} +func (s *Server) GetOrganizationFeatures(ctx context.Context, req *feature.GetOrganizationFeaturesRequest) (_ *feature.GetOrganizationFeaturesResponse, err error) { + return nil, status.Errorf(codes.Unimplemented, "method GetOrganizationFeatures not implemented") +} +func (s *Server) SetUserFeatures(ctx context.Context, req *feature.SetUserFeatureRequest) (_ *feature.SetUserFeaturesResponse, err error) { + return nil, status.Errorf(codes.Unimplemented, "method SetUserFeatures not implemented") +} +func (s *Server) ResetUserFeatures(ctx context.Context, req *feature.ResetUserFeaturesRequest) (_ *feature.ResetUserFeaturesResponse, err error) { + return nil, status.Errorf(codes.Unimplemented, "method ResetUserFeatures not implemented") +} +func (s *Server) GetUserFeatures(ctx context.Context, req *feature.GetUserFeaturesRequest) (_ *feature.GetUserFeaturesResponse, err error) { + return nil, status.Errorf(codes.Unimplemented, "method GetUserFeatures not implemented") +} diff --git a/internal/api/grpc/feature/v2/feature_integration_test.go b/internal/api/grpc/feature/v2/feature_integration_test.go new file mode 100644 index 000000000..338f6a70e --- /dev/null +++ b/internal/api/grpc/feature/v2/feature_integration_test.go @@ -0,0 +1,470 @@ +//go:build integration + +package feature_test + +import ( + "context" + "os" + "testing" + "time" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/integration" + feature "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta" + object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" +) + +var ( + SystemCTX context.Context + IamCTX context.Context + OrgCTX context.Context + Tester *integration.Tester + Client feature.FeatureServiceClient +) + +func TestMain(m *testing.M) { + os.Exit(func() int { + ctx, _, cancel := integration.Contexts(5 * time.Minute) + defer cancel() + Tester = integration.NewTester(ctx) + SystemCTX = Tester.WithAuthorization(ctx, integration.SystemUser) + IamCTX = Tester.WithAuthorization(ctx, integration.IAMOwner) + OrgCTX = Tester.WithAuthorization(ctx, integration.OrgOwner) + + defer Tester.Done() + Client = Tester.Client.FeatureV2 + + return m.Run() + }()) +} + +func TestServer_SetSystemFeatures(t *testing.T) { + type args struct { + ctx context.Context + req *feature.SetSystemFeaturesRequest + } + tests := []struct { + name string + args args + want *feature.SetSystemFeaturesResponse + wantErr bool + }{ + { + name: "permission error", + args: args{ + ctx: IamCTX, + req: &feature.SetSystemFeaturesRequest{ + OidcTriggerIntrospectionProjections: gu.Ptr(true), + }, + }, + wantErr: true, + }, + { + name: "no changes error", + args: args{ + ctx: SystemCTX, + req: &feature.SetSystemFeaturesRequest{}, + }, + wantErr: true, + }, + { + name: "success", + args: args{ + ctx: SystemCTX, + req: &feature.SetSystemFeaturesRequest{ + OidcTriggerIntrospectionProjections: gu.Ptr(true), + }, + }, + want: &feature.SetSystemFeaturesResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: "SYSTEM", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Cleanup(func() { + // make sure we have a clean state after each test + _, err := Client.ResetSystemFeatures(SystemCTX, &feature.ResetSystemFeaturesRequest{}) + require.NoError(t, err) + }) + got, err := Client.SetSystemFeatures(tt.args.ctx, tt.args.req) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + integration.AssertDetails(t, tt.want, got) + }) + } +} + +func TestServer_ResetSystemFeatures(t *testing.T) { + _, err := Client.SetSystemFeatures(SystemCTX, &feature.SetSystemFeaturesRequest{ + LoginDefaultOrg: gu.Ptr(true), + }) + require.NoError(t, err) + + tests := []struct { + name string + ctx context.Context + want *feature.ResetSystemFeaturesResponse + wantErr bool + }{ + { + name: "permission error", + ctx: IamCTX, + wantErr: true, + }, + { + name: "success", + ctx: SystemCTX, + want: &feature.ResetSystemFeaturesResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: "SYSTEM", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.ResetSystemFeatures(tt.ctx, &feature.ResetSystemFeaturesRequest{}) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + integration.AssertDetails(t, tt.want, got) + }) + } +} + +func TestServer_GetSystemFeatures(t *testing.T) { + type args struct { + ctx context.Context + req *feature.GetSystemFeaturesRequest + } + tests := []struct { + name string + prepare func(t *testing.T) + args args + want *feature.GetSystemFeaturesResponse + wantErr bool + }{ + { + name: "permission error", + args: args{ + ctx: IamCTX, + req: &feature.GetSystemFeaturesRequest{}, + }, + wantErr: true, + }, + { + name: "nothing set", + args: args{ + ctx: SystemCTX, + req: &feature.GetSystemFeaturesRequest{}, + }, + want: &feature.GetSystemFeaturesResponse{}, + }, + { + name: "some features", + prepare: func(t *testing.T) { + _, err := Client.SetSystemFeatures(SystemCTX, &feature.SetSystemFeaturesRequest{ + LoginDefaultOrg: gu.Ptr(true), + OidcTriggerIntrospectionProjections: gu.Ptr(false), + }) + require.NoError(t, err) + }, + args: args{ + ctx: SystemCTX, + req: &feature.GetSystemFeaturesRequest{}, + }, + want: &feature.GetSystemFeaturesResponse{ + LoginDefaultOrg: &feature.FeatureFlag{ + Enabled: true, + Source: feature.Source_SOURCE_SYSTEM, + }, + OidcTriggerIntrospectionProjections: &feature.FeatureFlag{ + Enabled: false, + Source: feature.Source_SOURCE_SYSTEM, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Cleanup(func() { + // make sure we have a clean state after each test + _, err := Client.ResetSystemFeatures(SystemCTX, &feature.ResetSystemFeaturesRequest{}) + require.NoError(t, err) + }) + if tt.prepare != nil { + tt.prepare(t) + } + got, err := Client.GetSystemFeatures(tt.args.ctx, tt.args.req) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assertFeatureFlag(t, tt.want.LoginDefaultOrg, got.LoginDefaultOrg) + assertFeatureFlag(t, tt.want.OidcTriggerIntrospectionProjections, got.OidcTriggerIntrospectionProjections) + assertFeatureFlag(t, tt.want.OidcLegacyIntrospection, got.OidcLegacyIntrospection) + }) + } +} + +func TestServer_SetInstanceFeatures(t *testing.T) { + type args struct { + ctx context.Context + req *feature.SetInstanceFeaturesRequest + } + tests := []struct { + name string + args args + want *feature.SetInstanceFeaturesResponse + wantErr bool + }{ + { + name: "permission error", + args: args{ + ctx: OrgCTX, + req: &feature.SetInstanceFeaturesRequest{ + OidcTriggerIntrospectionProjections: gu.Ptr(true), + }, + }, + wantErr: true, + }, + { + name: "no changes error", + args: args{ + ctx: IamCTX, + req: &feature.SetInstanceFeaturesRequest{}, + }, + wantErr: true, + }, + { + name: "success", + args: args{ + ctx: IamCTX, + req: &feature.SetInstanceFeaturesRequest{ + OidcTriggerIntrospectionProjections: gu.Ptr(true), + }, + }, + want: &feature.SetInstanceFeaturesResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Instance.InstanceID(), + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Cleanup(func() { + // make sure we have a clean state after each test + _, err := Client.ResetInstanceFeatures(IamCTX, &feature.ResetInstanceFeaturesRequest{}) + require.NoError(t, err) + }) + got, err := Client.SetInstanceFeatures(tt.args.ctx, tt.args.req) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + integration.AssertDetails(t, tt.want, got) + }) + } +} + +func TestServer_ResetInstanceFeatures(t *testing.T) { + _, err := Client.SetInstanceFeatures(IamCTX, &feature.SetInstanceFeaturesRequest{ + LoginDefaultOrg: gu.Ptr(true), + }) + require.NoError(t, err) + + tests := []struct { + name string + ctx context.Context + want *feature.ResetInstanceFeaturesResponse + wantErr bool + }{ + { + name: "permission error", + ctx: OrgCTX, + wantErr: true, + }, + { + name: "success", + ctx: IamCTX, + want: &feature.ResetInstanceFeaturesResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Instance.InstanceID(), + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.ResetInstanceFeatures(tt.ctx, &feature.ResetInstanceFeaturesRequest{}) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + integration.AssertDetails(t, tt.want, got) + }) + } +} + +func TestServer_GetInstanceFeatures(t *testing.T) { + _, err := Client.SetSystemFeatures(SystemCTX, &feature.SetSystemFeaturesRequest{ + OidcLegacyIntrospection: gu.Ptr(true), + }) + require.NoError(t, err) + t.Cleanup(func() { + _, err := Client.ResetSystemFeatures(SystemCTX, &feature.ResetSystemFeaturesRequest{}) + require.NoError(t, err) + }) + + type args struct { + ctx context.Context + req *feature.GetInstanceFeaturesRequest + } + tests := []struct { + name string + prepare func(t *testing.T) + args args + want *feature.GetInstanceFeaturesResponse + wantErr bool + }{ + { + name: "permission error", + args: args{ + ctx: OrgCTX, + req: &feature.GetInstanceFeaturesRequest{}, + }, + wantErr: true, + }, + { + name: "defaults, no inheritance", + args: args{ + ctx: IamCTX, + req: &feature.GetInstanceFeaturesRequest{}, + }, + want: &feature.GetInstanceFeaturesResponse{}, + }, + { + name: "defaults, inheritance", + args: args{ + ctx: IamCTX, + req: &feature.GetInstanceFeaturesRequest{ + Inheritance: true, + }, + }, + want: &feature.GetInstanceFeaturesResponse{ + LoginDefaultOrg: &feature.FeatureFlag{ + Enabled: false, + Source: feature.Source_SOURCE_UNSPECIFIED, + }, + OidcTriggerIntrospectionProjections: &feature.FeatureFlag{ + Enabled: false, + Source: feature.Source_SOURCE_UNSPECIFIED, + }, + OidcLegacyIntrospection: &feature.FeatureFlag{ + Enabled: true, + Source: feature.Source_SOURCE_SYSTEM, + }, + }, + }, + { + name: "some features, no inheritance", + prepare: func(t *testing.T) { + _, err := Client.SetInstanceFeatures(IamCTX, &feature.SetInstanceFeaturesRequest{ + LoginDefaultOrg: gu.Ptr(true), + OidcTriggerIntrospectionProjections: gu.Ptr(false), + }) + require.NoError(t, err) + }, + args: args{ + ctx: IamCTX, + req: &feature.GetInstanceFeaturesRequest{}, + }, + want: &feature.GetInstanceFeaturesResponse{ + LoginDefaultOrg: &feature.FeatureFlag{ + Enabled: true, + Source: feature.Source_SOURCE_INSTANCE, + }, + OidcTriggerIntrospectionProjections: &feature.FeatureFlag{ + Enabled: false, + Source: feature.Source_SOURCE_INSTANCE, + }, + }, + }, + { + name: "one feature, inheritance", + prepare: func(t *testing.T) { + _, err := Client.SetInstanceFeatures(IamCTX, &feature.SetInstanceFeaturesRequest{ + LoginDefaultOrg: gu.Ptr(true), + }) + require.NoError(t, err) + }, + args: args{ + ctx: IamCTX, + req: &feature.GetInstanceFeaturesRequest{ + Inheritance: true, + }, + }, + want: &feature.GetInstanceFeaturesResponse{ + LoginDefaultOrg: &feature.FeatureFlag{ + Enabled: true, + Source: feature.Source_SOURCE_INSTANCE, + }, + OidcTriggerIntrospectionProjections: &feature.FeatureFlag{ + Enabled: false, + Source: feature.Source_SOURCE_UNSPECIFIED, + }, + OidcLegacyIntrospection: &feature.FeatureFlag{ + Enabled: true, + Source: feature.Source_SOURCE_SYSTEM, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Cleanup(func() { + // make sure we have a clean state after each test + _, err := Client.ResetInstanceFeatures(IamCTX, &feature.ResetInstanceFeaturesRequest{}) + require.NoError(t, err) + }) + if tt.prepare != nil { + tt.prepare(t) + } + got, err := Client.GetInstanceFeatures(tt.args.ctx, tt.args.req) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assertFeatureFlag(t, tt.want.LoginDefaultOrg, got.LoginDefaultOrg) + assertFeatureFlag(t, tt.want.OidcTriggerIntrospectionProjections, got.OidcTriggerIntrospectionProjections) + assertFeatureFlag(t, tt.want.OidcLegacyIntrospection, got.OidcLegacyIntrospection) + }) + } +} + +func assertFeatureFlag(t *testing.T, expected, actual *feature.FeatureFlag) { + t.Helper() + assert.Equal(t, expected.GetEnabled(), actual.GetEnabled(), "enabled") + assert.Equal(t, expected.GetSource(), actual.GetSource(), "source") +} diff --git a/internal/api/grpc/feature/v2/server.go b/internal/api/grpc/feature/v2/server.go new file mode 100644 index 000000000..4208c4acf --- /dev/null +++ b/internal/api/grpc/feature/v2/server.go @@ -0,0 +1,47 @@ +package feature + +import ( + "google.golang.org/grpc" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/api/grpc/server" + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/query" + feature "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta" +) + +type Server struct { + feature.UnimplementedFeatureServiceServer + command *command.Commands + query *query.Queries +} + +func CreateServer( + command *command.Commands, + query *query.Queries, +) *Server { + return &Server{ + command: command, + query: query, + } +} + +func (s *Server) RegisterServer(grpcServer *grpc.Server) { + feature.RegisterFeatureServiceServer(grpcServer, s) +} + +func (s *Server) AppName() string { + return feature.FeatureService_ServiceDesc.ServiceName +} + +func (s *Server) MethodPrefix() string { + return feature.FeatureService_ServiceDesc.ServiceName +} + +func (s *Server) AuthMethods() authz.MethodMapping { + return feature.FeatureService_AuthMethods +} + +func (s *Server) RegisterGateway() server.RegisterGatewayFunc { + return feature.RegisterFeatureServiceHandler +} diff --git a/internal/api/grpc/instance/converter.go b/internal/api/grpc/instance/converter.go index 6bfb3e553..9bad4a3ef 100644 --- a/internal/api/grpc/instance/converter.go +++ b/internal/api/grpc/instance/converter.go @@ -22,9 +22,9 @@ func InstanceToPb(instance *query.Instance) *instance_pb.Instance { instance.Sequence, instance.CreationDate, instance.ChangeDate, - instance.InstanceID(), + instance.ID, ), - Id: instance.InstanceID(), + Id: instance.ID, Name: instance.Name, Domains: DomainsToPb(instance.Domains), Version: build.Version(), @@ -38,9 +38,9 @@ func InstanceDetailToPb(instance *query.Instance) *instance_pb.InstanceDetail { instance.Sequence, instance.CreationDate, instance.ChangeDate, - instance.InstanceID(), + instance.ID, ), - Id: instance.InstanceID(), + Id: instance.ID, Name: instance.Name, Domains: DomainsToPb(instance.Domains), Version: build.Version(), diff --git a/internal/api/grpc/server/middleware/instance_interceptor_test.go b/internal/api/grpc/server/middleware/instance_interceptor_test.go index 9960bdf48..2025970d9 100644 --- a/internal/api/grpc/server/middleware/instance_interceptor_test.go +++ b/internal/api/grpc/server/middleware/instance_interceptor_test.go @@ -12,6 +12,7 @@ import ( "google.golang.org/grpc/metadata" "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/feature" ) func Test_hostNameFromContext(t *testing.T) { @@ -208,3 +209,7 @@ func (m *mockInstance) RequestedHost() string { func (m *mockInstance) SecurityPolicyAllowedOrigins() []string { return nil } + +func (m *mockInstance) Features() feature.Features { + return feature.Features{} +} diff --git a/internal/api/grpc/system/feature.go b/internal/api/grpc/system/feature.go index 01a15977b..adacf26c3 100644 --- a/internal/api/grpc/system/feature.go +++ b/internal/api/grpc/system/feature.go @@ -4,6 +4,7 @@ import ( "context" object_pb "github.com/zitadel/zitadel/internal/api/grpc/object" + "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/zerrors" system_pb "github.com/zitadel/zitadel/pkg/grpc/system" @@ -22,12 +23,14 @@ func (s *Server) SetInstanceFeature(ctx context.Context, req *system_pb.SetInsta func (s *Server) setInstanceFeature(ctx context.Context, req *system_pb.SetInstanceFeatureRequest) (*domain.ObjectDetails, error) { feat := domain.Feature(req.FeatureId) - if !feat.IsAFeature() { + if feat != domain.FeatureLoginDefaultOrg { return nil, zerrors.ThrowInvalidArgument(nil, "SYST-SGV45", "Errors.Feature.NotExisting") } switch t := req.Value.(type) { case *system_pb.SetInstanceFeatureRequest_Bool: - return s.command.SetBooleanInstanceFeature(ctx, feat, t.Bool) + return s.command.SetInstanceFeatures(ctx, &command.InstanceFeatures{ + LoginDefaultOrg: &t.Bool, + }) default: return nil, zerrors.ThrowInvalidArgument(nil, "SYST-dag5g", "Errors.Feature.TypeNotSupported") } diff --git a/internal/api/grpc/system/instance.go b/internal/api/grpc/system/instance.go index 97cc33061..d684c37e8 100644 --- a/internal/api/grpc/system/instance.go +++ b/internal/api/grpc/system/instance.go @@ -151,7 +151,7 @@ func (s *Server) ListDomains(ctx context.Context, req *system_pb.ListDomainsRequ } func (s *Server) AddDomain(ctx context.Context, req *system_pb.AddDomainRequest) (*system_pb.AddDomainResponse, error) { - instance, err := s.query.Instance(ctx, true) + instance, err := s.query.InstanceByID(ctx) if err != nil { return nil, err } diff --git a/internal/api/http/middleware/instance_interceptor_test.go b/internal/api/http/middleware/instance_interceptor_test.go index 27ea2f967..444403fd5 100644 --- a/internal/api/http/middleware/instance_interceptor_test.go +++ b/internal/api/http/middleware/instance_interceptor_test.go @@ -14,6 +14,7 @@ import ( "github.com/zitadel/zitadel/internal/api/authz" zitadel_http "github.com/zitadel/zitadel/internal/api/http" + "github.com/zitadel/zitadel/internal/feature" ) func Test_instanceInterceptor_Handler(t *testing.T) { @@ -343,3 +344,7 @@ func (m *mockInstance) RequestedHost() string { func (m *mockInstance) SecurityPolicyAllowedOrigins() []string { return nil } + +func (m *mockInstance) Features() feature.Features { + return feature.Features{} +} diff --git a/internal/api/oidc/access_token.go b/internal/api/oidc/access_token.go index 4845c1ea6..05cb81d7f 100644 --- a/internal/api/oidc/access_token.go +++ b/internal/api/oidc/access_token.go @@ -9,6 +9,7 @@ import ( "github.com/zitadel/oidc/v3/pkg/oidc" "github.com/zitadel/oidc/v3/pkg/op" + "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/user/model" @@ -95,7 +96,7 @@ func (s *Server) assertClientScopesForPAT(ctx context.Context, token *accessToke if err != nil { return zerrors.ThrowInternal(err, "OIDC-Cyc78", "Errors.Internal") } - roles, err := s.query.SearchProjectRoles(ctx, s.features.TriggerIntrospectionProjections, &query.ProjectRoleSearchQueries{Queries: []query.SearchQuery{projectIDQuery}}) + roles, err := s.query.SearchProjectRoles(ctx, authz.GetFeatures(ctx).TriggerIntrospectionProjections, &query.ProjectRoleSearchQueries{Queries: []query.SearchQuery{projectIDQuery}}) if err != nil { return err } diff --git a/internal/api/oidc/introspect.go b/internal/api/oidc/introspect.go index 569088814..f2d557e67 100644 --- a/internal/api/oidc/introspect.go +++ b/internal/api/oidc/introspect.go @@ -10,6 +10,7 @@ import ( "github.com/zitadel/oidc/v3/pkg/oidc" "github.com/zitadel/oidc/v3/pkg/op" + "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/telemetry/tracing" @@ -23,10 +24,11 @@ func (s *Server) Introspect(ctx context.Context, r *op.Request[op.IntrospectionR span.EndWithError(err) }() - if s.features.LegacyIntrospection { + features := authz.GetFeatures(ctx) + if features.LegacyIntrospection { return s.LegacyServer.Introspect(ctx, r) } - if s.features.TriggerIntrospectionProjections { + if features.TriggerIntrospectionProjections { // Execute all triggers in one concurrent sweep. query.TriggerIntrospectionProjections(ctx) } diff --git a/internal/api/oidc/op.go b/internal/api/oidc/op.go index 11db1c040..2155284f0 100644 --- a/internal/api/oidc/op.go +++ b/internal/api/oidc/op.go @@ -42,7 +42,6 @@ type Config struct { DeviceAuth *DeviceAuthorizationConfig DefaultLoginURLV2 string DefaultLogoutURLV2 string - Features Features PublicKeyCacheMaxAge time.Duration } @@ -62,11 +61,6 @@ type Endpoint struct { URL string } -type Features struct { - TriggerIntrospectionProjections bool - LegacyIntrospection bool -} - type OPStorage struct { repo repository.Repository command *command.Commands @@ -128,7 +122,6 @@ func NewServer( server := &Server{ LegacyServer: op.NewLegacyServer(provider, endpoints(config.CustomEndpoints)), - features: config.Features, repo: repo, query: query, command: command, diff --git a/internal/api/oidc/server.go b/internal/api/oidc/server.go index ad83239ba..ff2d68a24 100644 --- a/internal/api/oidc/server.go +++ b/internal/api/oidc/server.go @@ -21,7 +21,6 @@ import ( type Server struct { http.Handler *op.LegacyServer - features Features repo repository.Repository query *query.Queries diff --git a/internal/api/ui/login/login.go b/internal/api/ui/login/login.go index ccc083277..5c431f1f2 100644 --- a/internal/api/ui/login/login.go +++ b/internal/api/ui/login/login.go @@ -9,7 +9,6 @@ import ( "github.com/gorilla/csrf" "github.com/gorilla/mux" - "github.com/zitadel/zitadel/feature" "github.com/zitadel/zitadel/internal/api/authz" http_utils "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/api/http/middleware" @@ -39,7 +38,6 @@ type Login struct { samlAuthCallbackURL func(context.Context, string) string idpConfigAlg crypto.EncryptionAlgorithm userCodeAlg crypto.EncryptionAlgorithm - featureCheck feature.Checker } type Config struct { @@ -76,7 +74,6 @@ func CreateLogin(config Config, userCodeAlg crypto.EncryptionAlgorithm, idpConfigAlg crypto.EncryptionAlgorithm, csrfCookieKey []byte, - featureCheck feature.Checker, ) (*Login, error) { login := &Login{ oidcAuthCallbackURL: oidcAuthCallbackURL, @@ -89,7 +86,6 @@ func CreateLogin(config Config, authRepo: authRepo, idpConfigAlg: idpConfigAlg, userCodeAlg: userCodeAlg, - featureCheck: featureCheck, } csrfInterceptor := createCSRFInterceptor(config.CSRFCookieName, csrfCookieKey, externalSecure, login.csrfErrorHandler()) cacheInterceptor := createCacheInterceptor(config.Cache.MaxAge, config.Cache.SharedMaxAge, assetCache) diff --git a/internal/api/ui/login/renderer.go b/internal/api/ui/login/renderer.go index c5f6bd50e..a44680090 100644 --- a/internal/api/ui/login/renderer.go +++ b/internal/api/ui/login/renderer.go @@ -524,12 +524,12 @@ func (l *Login) getOrgID(r *http.Request, authReq *domain.AuthRequest) string { } func (l *Login) getPrivateLabelingID(r *http.Request, authReq *domain.AuthRequest) string { - defaultID := authz.GetInstance(r.Context()).DefaultOrganisationID() - f, err := l.featureCheck.CheckInstanceBooleanFeature(r.Context(), domain.FeatureLoginDefaultOrg) - logging.OnError(err).Warnf("could not check feature %s", domain.FeatureLoginDefaultOrg) - if !f.Boolean { - defaultID = authz.GetInstance(r.Context()).InstanceID() + instance := authz.GetInstance(r.Context()) + defaultID := instance.DefaultOrganisationID() + if !instance.Features().LoginDefaultOrg { + defaultID = instance.InstanceID() } + if authReq != nil { return authReq.PrivateLabelingOrgID(defaultID) } diff --git a/internal/auth/repository/eventsourcing/eventstore/auth_request.go b/internal/auth/repository/eventsourcing/eventstore/auth_request.go index 6511d2c38..30b7f332a 100644 --- a/internal/auth/repository/eventsourcing/eventstore/auth_request.go +++ b/internal/auth/repository/eventsourcing/eventstore/auth_request.go @@ -7,7 +7,6 @@ import ( "github.com/zitadel/logging" - "github.com/zitadel/zitadel/feature" "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/auth/repository/eventsourcing/view" cache "github.com/zitadel/zitadel/internal/auth_request/repository" @@ -50,8 +49,6 @@ type AuthRequestRepo struct { ApplicationProvider applicationProvider CustomTextProvider customTextProvider - FeatureCheck feature.Checker - IdGenerator id.Generator } @@ -656,16 +653,15 @@ func (repo *AuthRequestRepo) getLoginPolicyAndIDPProviders(ctx context.Context, } func (repo *AuthRequestRepo) fillPolicies(ctx context.Context, request *domain.AuthRequest) error { + instance := authz.GetInstance(ctx) orgID := request.RequestedOrgID if orgID == "" { orgID = request.UserOrgID } if orgID == "" { orgID = authz.GetInstance(ctx).DefaultOrganisationID() - f, err := repo.FeatureCheck.CheckInstanceBooleanFeature(ctx, domain.FeatureLoginDefaultOrg) - logging.WithFields("authReq", request.ID).OnError(err).Warnf("could not check feature %s", domain.FeatureLoginDefaultOrg) - if !f.Boolean { - orgID = authz.GetInstance(ctx).InstanceID() + if !instance.Features().LoginDefaultOrg { + orgID = instance.InstanceID() } } @@ -692,7 +688,7 @@ func (repo *AuthRequestRepo) fillPolicies(ctx context.Context, request *domain.A return err } request.LabelPolicy = labelPolicy - defaultLoginTranslations, err := repo.getLoginTexts(ctx, authz.GetInstance(ctx).InstanceID()) + defaultLoginTranslations, err := repo.getLoginTexts(ctx, instance.InstanceID()) if err != nil { return err } diff --git a/internal/auth/repository/eventsourcing/repository.go b/internal/auth/repository/eventsourcing/repository.go index 84be71631..19cea1c6b 100644 --- a/internal/auth/repository/eventsourcing/repository.go +++ b/internal/auth/repository/eventsourcing/repository.go @@ -3,7 +3,6 @@ package eventsourcing import ( "context" - "github.com/zitadel/zitadel/feature" "github.com/zitadel/zitadel/internal/auth/repository/eventsourcing/eventstore" auth_handler "github.com/zitadel/zitadel/internal/auth/repository/eventsourcing/handler" auth_view "github.com/zitadel/zitadel/internal/auth/repository/eventsourcing/view" @@ -77,7 +76,6 @@ func Start(ctx context.Context, conf Config, systemDefaults sd.SystemDefaults, c ProjectProvider: queryView, ApplicationProvider: queries, CustomTextProvider: queries, - FeatureCheck: feature.NewCheck(esV2), IdGenerator: id.SonyFlakeGenerator(), }, eventstore.TokenRepo{ diff --git a/internal/command/instance.go b/internal/command/instance.go index 736b93c31..e8d2a1078 100644 --- a/internal/command/instance.go +++ b/internal/command/instance.go @@ -15,7 +15,6 @@ import ( "github.com/zitadel/zitadel/internal/i18n" "github.com/zitadel/zitadel/internal/id" "github.com/zitadel/zitadel/internal/notification/channels/smtp" - "github.com/zitadel/zitadel/internal/repository/feature" "github.com/zitadel/zitadel/internal/repository/instance" "github.com/zitadel/zitadel/internal/repository/limits" "github.com/zitadel/zitadel/internal/repository/org" @@ -110,7 +109,7 @@ type InstanceSetup struct { SMTPConfiguration *smtp.Config OIDCSettings *OIDCSettings Quotas *SetQuotas - Features map[domain.Feature]any + Features *InstanceFeatures Limits *SetLimits Restrictions *SetRestrictions } @@ -313,9 +312,7 @@ func (c *Commands) SetUpInstance(ctx context.Context, setup *InstanceSetup) (str setupCustomDomain(c, &validations, instanceAgg, setup.CustomDomain) setupSMTPSettings(c, &validations, setup.SMTPConfiguration, instanceAgg) setupOIDCSettings(c, &validations, setup.OIDCSettings, instanceAgg) - if err := setupFeatures(c, &validations, setup.Features, instanceID); err != nil { - return "", "", nil, nil, err - } + setupFeatures(&validations, setup.Features, instanceID) setupLimits(c, &validations, limitsAgg, setup.Limits) setupRestrictions(c, &validations, restrictionsAgg, setup.Restrictions) @@ -368,20 +365,8 @@ func setupQuotas(commands *Commands, validations *[]preparation.Validation, setQ return nil } -func setupFeatures(commands *Commands, validations *[]preparation.Validation, enableFeatures map[domain.Feature]any, instanceID string) error { - for f, value := range enableFeatures { - switch v := value.(type) { - case bool: - wm, err := NewInstanceFeatureWriteModel[feature.Boolean](instanceID, f) - if err != nil { - return err - } - *validations = append(*validations, prepareSetFeature(wm, feature.Boolean{Boolean: v}, commands.idGenerator)) - default: - return zerrors.ThrowInvalidArgument(nil, "INST-GE4tg", "Errors.Feature.TypeNotSupported") - } - } - return nil +func setupFeatures(validations *[]preparation.Validation, features *InstanceFeatures, instanceID string) { + *validations = append(*validations, prepareSetFeatures(instanceID, features)) } func setupOIDCSettings(commands *Commands, validations *[]preparation.Validation, oidcSettings *OIDCSettings, instanceAgg *instance.Aggregate) { diff --git a/internal/command/instance_feature.go b/internal/command/instance_feature.go deleted file mode 100644 index 4ee36b94c..000000000 --- a/internal/command/instance_feature.go +++ /dev/null @@ -1,63 +0,0 @@ -package command - -import ( - "context" - - "github.com/zitadel/zitadel/internal/api/authz" - "github.com/zitadel/zitadel/internal/command/preparation" - "github.com/zitadel/zitadel/internal/domain" - "github.com/zitadel/zitadel/internal/eventstore" - "github.com/zitadel/zitadel/internal/id" - "github.com/zitadel/zitadel/internal/repository/feature" - "github.com/zitadel/zitadel/internal/zerrors" -) - -func (c *Commands) SetBooleanInstanceFeature(ctx context.Context, f domain.Feature, value bool) (*domain.ObjectDetails, error) { - instanceID := authz.GetInstance(ctx).InstanceID() - writeModel, err := NewInstanceFeatureWriteModel[feature.Boolean](instanceID, f) - if err != nil { - return nil, err - } - cmds, err := preparation.PrepareCommands(ctx, c.eventstore.Filter, - prepareSetFeature(writeModel, feature.Boolean{Boolean: value}, c.idGenerator)) - if err != nil { - return nil, err - } - if len(cmds) == 0 { - return writeModelToObjectDetails(&writeModel.FeatureWriteModel.WriteModel), nil - } - pushedEvents, err := c.eventstore.Push(ctx, cmds...) - if err != nil { - return nil, err - } - return pushedEventsToObjectDetails(pushedEvents), nil -} - -func prepareSetFeature[T feature.SetEventType](writeModel *InstanceFeatureWriteModel[T], value T, idGenerator id.Generator) preparation.Validation { - return func() (preparation.CreateCommands, error) { - if !writeModel.feature.IsAFeature() || writeModel.feature == domain.FeatureUnspecified { - return nil, zerrors.ThrowPreconditionFailed(nil, "FEAT-JK3td", "Errors.Feature.NotExisting") - } - return func(ctx context.Context, filter preparation.FilterToQueryReducer) ([]eventstore.Command, error) { - events, err := filter(ctx, writeModel.Query()) - if err != nil { - return nil, err - } - writeModel.AppendEvents(events...) - if err = writeModel.Reduce(); err != nil { - return nil, err - } - if len(events) == 0 { - writeModel.AggregateID, err = idGenerator.Next() - if err != nil { - return nil, err - } - } - setEvent, err := writeModel.Set(ctx, value) - if err != nil || setEvent == nil { - return nil, err - } - return []eventstore.Command{setEvent}, nil - }, nil - } -} diff --git a/internal/command/instance_feature_model.go b/internal/command/instance_feature_model.go deleted file mode 100644 index 5014b1c76..000000000 --- a/internal/command/instance_feature_model.go +++ /dev/null @@ -1,81 +0,0 @@ -package command - -import ( - "context" - - "github.com/zitadel/zitadel/internal/domain" - "github.com/zitadel/zitadel/internal/eventstore" - "github.com/zitadel/zitadel/internal/repository/feature" - "github.com/zitadel/zitadel/internal/zerrors" -) - -type FeatureWriteModel[T feature.SetEventType] struct { - eventstore.WriteModel - - feature domain.Feature - - Value T -} - -func NewFeatureWriteModel[T feature.SetEventType](instanceID, resourceOwner string, feature domain.Feature) (*FeatureWriteModel[T], error) { - wm := &FeatureWriteModel[T]{ - WriteModel: eventstore.WriteModel{ - InstanceID: instanceID, - ResourceOwner: resourceOwner, - }, - feature: feature, - } - if wm.Value.FeatureType() != feature.Type() { - return nil, zerrors.ThrowPreconditionFailed(nil, "FEAT-AS4k1", "Errors.Feature.InvalidValue") - } - return wm, nil -} -func (wm *FeatureWriteModel[T]) Set(ctx context.Context, value T) (event *feature.SetEvent[T], err error) { - if wm.Value == value { - return nil, nil - } - return feature.NewSetEvent[T]( - ctx, - &feature.NewAggregate(wm.AggregateID, wm.ResourceOwner).Aggregate, - wm.eventType(), - value, - ), nil -} - -func (wm *FeatureWriteModel[T]) Query() *eventstore.SearchQueryBuilder { - return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). - AddQuery(). - AggregateTypes(feature.AggregateType). - EventTypes(wm.eventType()). - Builder() -} - -func (wm *FeatureWriteModel[T]) Reduce() error { - for _, event := range wm.Events { - switch e := event.(type) { - case *feature.SetEvent[T]: - wm.Value = e.Value - default: - return zerrors.ThrowPreconditionFailed(nil, "FEAT-SDfjk", "Errors.Feature.TypeNotSupported") - } - } - return wm.WriteModel.Reduce() -} - -func (wm *FeatureWriteModel[T]) eventType() eventstore.EventType { - return feature.EventTypeFromFeature(wm.feature) -} - -type InstanceFeatureWriteModel[T feature.SetEventType] struct { - FeatureWriteModel[T] -} - -func NewInstanceFeatureWriteModel[T feature.SetEventType](instanceID string, feature domain.Feature) (*InstanceFeatureWriteModel[T], error) { - wm, err := NewFeatureWriteModel[T](instanceID, instanceID, feature) - if err != nil { - return nil, err - } - return &InstanceFeatureWriteModel[T]{ - FeatureWriteModel: *wm, - }, nil -} diff --git a/internal/command/instance_feature_test.go b/internal/command/instance_feature_test.go deleted file mode 100644 index 56933b900..000000000 --- a/internal/command/instance_feature_test.go +++ /dev/null @@ -1,170 +0,0 @@ -package command - -import ( - "context" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/zitadel/zitadel/internal/api/authz" - "github.com/zitadel/zitadel/internal/domain" - "github.com/zitadel/zitadel/internal/eventstore" - "github.com/zitadel/zitadel/internal/id" - "github.com/zitadel/zitadel/internal/id/mock" - "github.com/zitadel/zitadel/internal/repository/feature" - "github.com/zitadel/zitadel/internal/repository/instance" - "github.com/zitadel/zitadel/internal/zerrors" -) - -func TestCommands_SetBooleanInstanceFeature(t *testing.T) { - type fields struct { - eventstore func(t *testing.T) *eventstore.Eventstore - idGenerator id.Generator - } - type args struct { - ctx context.Context - f domain.Feature - value bool - } - type res struct { - details *domain.ObjectDetails - err error - } - tests := []struct { - name string - fields fields - args args - res res - }{ - { - "unknown feature", - fields{ - eventstore: expectEventstore(), - }, - args{ - ctx: authz.WithInstanceID(context.Background(), "instanceID"), - f: domain.FeatureUnspecified, - value: true, - }, - res{ - err: zerrors.ThrowPreconditionFailed(nil, "FEAT-AS4k1", "Errors.Feature.InvalidValue"), - }, - }, - { - "wrong type", - fields{ - eventstore: expectEventstore( - expectFilter( - eventFromEventPusherWithInstanceID("instanceID", - // as there's currently no other [feature.SetEventType] than [feature.Boolean], - // we need to use a completely other event type to demonstrate the behaviour - instance.NewInstanceAddedEvent(context.Background(), &instance.NewAggregate("instanceID").Aggregate, - "instance", - ), - ), - ), - ), - }, - args{ - ctx: authz.WithInstanceID(context.Background(), "instanceID"), - f: domain.FeatureLoginDefaultOrg, - value: true, - }, - res{ - err: zerrors.ThrowPreconditionFailed(nil, "FEAT-SDfjk", "Errors.Feature.TypeNotSupported"), - }, - }, - { - "first set", - fields{ - eventstore: expectEventstore( - expectFilter(), - expectPush( - feature.NewSetEvent[feature.Boolean](context.Background(), &feature.NewAggregate("featureID", "instanceID").Aggregate, - feature.EventTypeFromFeature(domain.FeatureLoginDefaultOrg), - feature.Boolean{Boolean: true}, - ), - ), - ), - idGenerator: mock.ExpectID(t, "featureID"), - }, - args{ - ctx: authz.WithInstanceID(context.Background(), "instanceID"), - f: domain.FeatureLoginDefaultOrg, - value: true, - }, - res{ - details: &domain.ObjectDetails{ - ResourceOwner: "instanceID", - }, - }, - }, - { - "update flag", - fields{ - eventstore: expectEventstore( - expectFilter( - eventFromEventPusherWithInstanceID("instanceID", - feature.NewSetEvent[feature.Boolean](context.Background(), &feature.NewAggregate("featureID", "instanceID").Aggregate, - feature.EventTypeFromFeature(domain.FeatureLoginDefaultOrg), - feature.Boolean{Boolean: true}, - ), - ), - ), - expectPush( - feature.NewSetEvent[feature.Boolean](context.Background(), &feature.NewAggregate("featureID", "instanceID").Aggregate, - feature.EventTypeFromFeature(domain.FeatureLoginDefaultOrg), - feature.Boolean{Boolean: false}, - ), - ), - ), - }, - args{ - ctx: authz.WithInstanceID(context.Background(), "instanceID"), - f: domain.FeatureLoginDefaultOrg, - value: false, - }, - res{ - details: &domain.ObjectDetails{ - ResourceOwner: "instanceID", - }, - }, - }, - { - "no change", - fields{ - eventstore: expectEventstore( - expectFilter( - eventFromEventPusherWithInstanceID("instanceID", - feature.NewSetEvent[feature.Boolean](context.Background(), &feature.NewAggregate("featureID", "instanceID").Aggregate, - feature.EventTypeFromFeature(domain.FeatureLoginDefaultOrg), - feature.Boolean{Boolean: true}, - ), - ), - ), - ), - }, - args{ - ctx: authz.WithInstanceID(context.Background(), "instanceID"), - f: domain.FeatureLoginDefaultOrg, - value: true, - }, - res{ - details: &domain.ObjectDetails{ - ResourceOwner: "instanceID", - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - c := &Commands{ - eventstore: tt.fields.eventstore(t), - idGenerator: tt.fields.idGenerator, - } - got, err := c.SetBooleanInstanceFeature(tt.args.ctx, tt.args.f, tt.args.value) - assert.ErrorIs(t, err, tt.res.err) - assert.Equal(t, tt.res.details, got) - }) - } -} diff --git a/internal/command/instance_features.go b/internal/command/instance_features.go new file mode 100644 index 000000000..171ef8f6b --- /dev/null +++ b/internal/command/instance_features.go @@ -0,0 +1,69 @@ +package command + +import ( + "context" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/command/preparation" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/feature/feature_v2" + "github.com/zitadel/zitadel/internal/zerrors" +) + +type InstanceFeatures struct { + LoginDefaultOrg *bool + TriggerIntrospectionProjections *bool + LegacyIntrospection *bool +} + +func (m *InstanceFeatures) isEmpty() bool { + return m.LoginDefaultOrg == nil && + m.TriggerIntrospectionProjections == nil && + m.LegacyIntrospection == nil +} + +func (c *Commands) SetInstanceFeatures(ctx context.Context, f *InstanceFeatures) (*domain.ObjectDetails, error) { + if f.isEmpty() { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-Vigh1", "Errors.NoChangesFound") + } + wm := NewInstanceFeaturesWriteModel(authz.GetInstance(ctx).InstanceID()) + if err := c.eventstore.FilterToQueryReducer(ctx, wm); err != nil { + return nil, err + } + cmds := wm.setCommands(ctx, f) + if len(cmds) == 0 { + return writeModelToObjectDetails(wm.WriteModel), nil + } + events, err := c.eventstore.Push(ctx, cmds...) + if err != nil { + return nil, err + } + return pushedEventsToObjectDetails(events), nil +} + +func prepareSetFeatures(instanceID string, f *InstanceFeatures) preparation.Validation { + return func() (preparation.CreateCommands, error) { + return func(ctx context.Context, _ preparation.FilterToQueryReducer) ([]eventstore.Command, error) { + wm := NewInstanceFeaturesWriteModel(instanceID) + return wm.setCommands(ctx, f), nil + }, nil + } +} + +func (c *Commands) ResetInstanceFeatures(ctx context.Context) (*domain.ObjectDetails, error) { + instanceID := authz.GetInstance(ctx).InstanceID() + wm := NewInstanceFeaturesWriteModel(instanceID) + if err := c.eventstore.FilterToQueryReducer(ctx, wm); err != nil { + return nil, err + } + if wm.isEmpty() { + return writeModelToObjectDetails(wm.WriteModel), nil + } + aggregate := feature_v2.NewAggregate(instanceID, instanceID) + events, err := c.eventstore.Push(ctx, feature_v2.NewResetEvent(ctx, aggregate, feature_v2.InstanceResetEventType)) + if err != nil { + return nil, err + } + return pushedEventsToObjectDetails(events), nil +} diff --git a/internal/command/instance_features_model.go b/internal/command/instance_features_model.go new file mode 100644 index 000000000..ff5ae99ba --- /dev/null +++ b/internal/command/instance_features_model.go @@ -0,0 +1,93 @@ +package command + +import ( + "context" + + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/feature" + feature_v1 "github.com/zitadel/zitadel/internal/repository/feature" + "github.com/zitadel/zitadel/internal/repository/feature/feature_v2" +) + +type InstanceFeaturesWriteModel struct { + *eventstore.WriteModel + InstanceFeatures +} + +func NewInstanceFeaturesWriteModel(instanceID string) *InstanceFeaturesWriteModel { + m := &InstanceFeaturesWriteModel{ + WriteModel: &eventstore.WriteModel{ + AggregateID: instanceID, + ResourceOwner: instanceID, + }, + } + return m +} + +func (m *InstanceFeaturesWriteModel) Reduce() (err error) { + for _, event := range m.Events { + switch e := event.(type) { + case *feature_v2.ResetEvent: + m.reduceReset() + case *feature_v1.SetEvent[feature_v1.Boolean]: + err = m.reduceBoolFeature( + feature_v1.DefaultLoginInstanceEventToV2(e), + ) + case *feature_v2.SetEvent[bool]: + err = m.reduceBoolFeature(e) + } + if err != nil { + return err + } + } + return m.WriteModel.Reduce() +} + +func (m *InstanceFeaturesWriteModel) Query() *eventstore.SearchQueryBuilder { + return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + AwaitOpenTransactions(). + AddQuery(). + AggregateTypes(feature_v2.AggregateType). + AggregateIDs(m.AggregateID). + EventTypes( + feature_v1.DefaultLoginInstanceEventType, + feature_v2.InstanceResetEventType, + feature_v2.InstanceLoginDefaultOrgEventType, + feature_v2.InstanceTriggerIntrospectionProjectionsEventType, + feature_v2.InstanceLegacyIntrospectionEventType, + ). + Builder().ResourceOwner(m.ResourceOwner) +} + +func (m *InstanceFeaturesWriteModel) reduceReset() { + m.LoginDefaultOrg = nil + m.TriggerIntrospectionProjections = nil + m.LegacyIntrospection = nil +} + +func (m *InstanceFeaturesWriteModel) reduceBoolFeature(event *feature_v2.SetEvent[bool]) error { + _, key, err := event.FeatureInfo() + if err != nil { + return err + } + switch key { + case feature.KeyUnspecified: + return nil + case feature.KeyLoginDefaultOrg: + m.LoginDefaultOrg = &event.Value + case feature.KeyTriggerIntrospectionProjections: + m.TriggerIntrospectionProjections = &event.Value + case feature.KeyLegacyIntrospection: + m.LegacyIntrospection = &event.Value + } + return nil +} + +func (wm *InstanceFeaturesWriteModel) setCommands(ctx context.Context, f *InstanceFeatures) []eventstore.Command { + aggregate := feature_v2.NewAggregate(wm.AggregateID, wm.ResourceOwner) + cmds := make([]eventstore.Command, 0, len(feature.KeyValues())-1) + cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.LoginDefaultOrg, f.LoginDefaultOrg, feature_v2.InstanceLoginDefaultOrgEventType) + cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.TriggerIntrospectionProjections, f.TriggerIntrospectionProjections, feature_v2.InstanceTriggerIntrospectionProjectionsEventType) + cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.LegacyIntrospection, f.LegacyIntrospection, feature_v2.InstanceLegacyIntrospectionEventType) + return cmds +} diff --git a/internal/command/instance_features_test.go b/internal/command/instance_features_test.go new file mode 100644 index 000000000..fd3439ea3 --- /dev/null +++ b/internal/command/instance_features_test.go @@ -0,0 +1,323 @@ +package command + +import ( + "context" + "io" + "testing" + + "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" + "github.com/zitadel/zitadel/internal/eventstore" + feature_v1 "github.com/zitadel/zitadel/internal/repository/feature" + "github.com/zitadel/zitadel/internal/repository/feature/feature_v2" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func TestCommands_SetInstanceFeatures(t *testing.T) { + ctx := authz.WithInstanceID(context.Background(), "instance1") + aggregate := feature_v2.NewAggregate("instance1", "instance1") + + type args struct { + ctx context.Context + f *InstanceFeatures + } + tests := []struct { + name string + eventstore func(*testing.T) *eventstore.Eventstore + args args + want *domain.ObjectDetails + wantErr error + }{ + { + name: "filter error", + eventstore: expectEventstore( + expectFilterError(io.ErrClosedPipe), + ), + args: args{ctx, &InstanceFeatures{ + LoginDefaultOrg: gu.Ptr(true), + }}, + wantErr: io.ErrClosedPipe, + }, + { + name: "all nil, No Change", + eventstore: expectEventstore(), + args: args{ctx, &InstanceFeatures{}}, + wantErr: zerrors.ThrowInvalidArgument(nil, "COMMAND-Vigh1", "Errors.NoChangesFound"), + }, + { + name: "set LoginDefaultOrg", + eventstore: expectEventstore( + expectFilter(), + expectPush( + feature_v2.NewSetEvent[bool]( + ctx, aggregate, + feature_v2.InstanceLoginDefaultOrgEventType, true, + ), + ), + ), + args: args{ctx, &InstanceFeatures{ + LoginDefaultOrg: gu.Ptr(true), + }}, + want: &domain.ObjectDetails{ + ResourceOwner: "instance1", + }, + }, + { + name: "set LoginDefaultOrg, update from v1", + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher(feature_v1.NewSetEvent[feature_v1.Boolean]( + ctx, &eventstore.Aggregate{ + ID: "instance1", + ResourceOwner: "instance1", + }, + feature_v1.DefaultLoginInstanceEventType, + feature_v1.Boolean{ + Boolean: false, + }, + )), + ), + expectPush( + feature_v2.NewSetEvent[bool]( + ctx, aggregate, + feature_v2.InstanceLoginDefaultOrgEventType, true, + ), + ), + ), + args: args{ctx, &InstanceFeatures{ + LoginDefaultOrg: gu.Ptr(true), + }}, + want: &domain.ObjectDetails{ + ResourceOwner: "instance1", + }, + }, + { + name: "set TriggerIntrospectionProjections", + eventstore: expectEventstore( + expectFilter(), + expectPush( + feature_v2.NewSetEvent[bool]( + ctx, aggregate, + feature_v2.InstanceTriggerIntrospectionProjectionsEventType, true, + ), + ), + ), + args: args{ctx, &InstanceFeatures{ + TriggerIntrospectionProjections: gu.Ptr(true), + }}, + want: &domain.ObjectDetails{ + ResourceOwner: "instance1", + }, + }, + { + name: "set LegacyIntrospection", + eventstore: expectEventstore( + expectFilter(), + expectPush( + feature_v2.NewSetEvent[bool]( + ctx, aggregate, + feature_v2.InstanceLegacyIntrospectionEventType, true, + ), + ), + ), + args: args{ctx, &InstanceFeatures{ + LegacyIntrospection: gu.Ptr(true), + }}, + want: &domain.ObjectDetails{ + ResourceOwner: "instance1", + }, + }, + { + name: "push error", + eventstore: expectEventstore( + expectFilter(), + expectPushFailed(io.ErrClosedPipe, + feature_v2.NewSetEvent[bool]( + ctx, aggregate, + feature_v2.InstanceLegacyIntrospectionEventType, true, + ), + ), + ), + args: args{ctx, &InstanceFeatures{ + LegacyIntrospection: gu.Ptr(true), + }}, + wantErr: io.ErrClosedPipe, + }, + { + name: "set all", + eventstore: expectEventstore( + expectFilter(), + expectPush( + feature_v2.NewSetEvent[bool]( + ctx, aggregate, + feature_v2.InstanceLoginDefaultOrgEventType, true, + ), + feature_v2.NewSetEvent[bool]( + ctx, aggregate, + feature_v2.InstanceTriggerIntrospectionProjectionsEventType, false, + ), + feature_v2.NewSetEvent[bool]( + ctx, aggregate, + feature_v2.InstanceLegacyIntrospectionEventType, true, + ), + ), + ), + args: args{ctx, &InstanceFeatures{ + LoginDefaultOrg: gu.Ptr(true), + TriggerIntrospectionProjections: gu.Ptr(false), + LegacyIntrospection: gu.Ptr(true), + }}, + want: &domain.ObjectDetails{ + ResourceOwner: "instance1", + }, + }, + { + name: "set only updated", + eventstore: expectEventstore( + // throw in some set events, reset and set again. + expectFilter( + eventFromEventPusher(feature_v2.NewSetEvent[bool]( + ctx, aggregate, + feature_v2.InstanceLoginDefaultOrgEventType, true, + )), + eventFromEventPusher(feature_v2.NewSetEvent[bool]( + ctx, aggregate, + feature_v2.InstanceTriggerIntrospectionProjectionsEventType, false, + )), + eventFromEventPusher(feature_v2.NewResetEvent( + ctx, aggregate, + feature_v2.InstanceResetEventType, + )), + eventFromEventPusher(feature_v2.NewSetEvent[bool]( + ctx, aggregate, + feature_v2.InstanceLoginDefaultOrgEventType, false, + )), + eventFromEventPusher(feature_v2.NewSetEvent[bool]( + ctx, aggregate, + feature_v2.InstanceLegacyIntrospectionEventType, true, + )), + ), + expectPush( + feature_v2.NewSetEvent[bool]( + ctx, aggregate, + feature_v2.InstanceLoginDefaultOrgEventType, true, + ), + feature_v2.NewSetEvent[bool]( + ctx, aggregate, + feature_v2.InstanceTriggerIntrospectionProjectionsEventType, false, + ), + ), + ), + args: args{ctx, &InstanceFeatures{ + LoginDefaultOrg: gu.Ptr(true), + TriggerIntrospectionProjections: gu.Ptr(false), + LegacyIntrospection: gu.Ptr(true), + }}, + want: &domain.ObjectDetails{ + ResourceOwner: "instance1", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.eventstore(t), + } + got, err := c.SetInstanceFeatures(tt.args.ctx, tt.args.f) + require.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestCommands_ResetInstanceFeatures(t *testing.T) { + ctx := authz.WithInstanceID(context.Background(), "instance1") + aggregate := feature_v2.NewAggregate("instance1", "instance1") + tests := []struct { + name string + eventstore func(*testing.T) *eventstore.Eventstore + want *domain.ObjectDetails + wantErr error + }{ + { + name: "filter error", + eventstore: expectEventstore( + expectFilterError(io.ErrClosedPipe), + ), + wantErr: io.ErrClosedPipe, + }, + { + name: "push error", + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher(feature_v2.NewSetEvent[bool]( + ctx, aggregate, + feature_v2.InstanceLoginDefaultOrgEventType, true, + )), + ), + expectPushFailed(io.ErrClosedPipe, + feature_v2.NewResetEvent(ctx, aggregate, feature_v2.InstanceResetEventType), + ), + ), + wantErr: io.ErrClosedPipe, + }, + { + name: "success", + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher(feature_v2.NewSetEvent[bool]( + ctx, aggregate, + feature_v2.InstanceLoginDefaultOrgEventType, true, + )), + ), + expectPush( + feature_v2.NewResetEvent(ctx, aggregate, feature_v2.InstanceResetEventType), + ), + ), + want: &domain.ObjectDetails{ + ResourceOwner: "instance1", + }, + }, + { + name: "no change after previous reset", + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher(feature_v2.NewSetEvent[bool]( + ctx, aggregate, + feature_v2.InstanceLoginDefaultOrgEventType, true, + )), + eventFromEventPusher(feature_v2.NewResetEvent( + ctx, aggregate, + feature_v2.InstanceResetEventType, + )), + ), + ), + want: &domain.ObjectDetails{ + ResourceOwner: "instance1", + }, + }, + { + name: "no change without previous events", + eventstore: expectEventstore( + expectFilter(), + ), + want: &domain.ObjectDetails{ + ResourceOwner: "instance1", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.eventstore(t), + } + got, err := c.ResetInstanceFeatures(ctx) + require.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/command/main_test.go b/internal/command/main_test.go index faed3c304..db2866c41 100644 --- a/internal/command/main_test.go +++ b/internal/command/main_test.go @@ -17,6 +17,7 @@ import ( "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore/repository" "github.com/zitadel/zitadel/internal/eventstore/repository/mock" + "github.com/zitadel/zitadel/internal/feature" "github.com/zitadel/zitadel/internal/zerrors" ) @@ -215,6 +216,10 @@ func (m *mockInstance) SecurityPolicyAllowedOrigins() []string { return nil } +func (m *mockInstance) Features() feature.Features { + return feature.Features{} +} + func newMockPermissionCheckAllowed() domain.PermissionCheck { return func(ctx context.Context, permission, orgID, resourceID string) (err error) { return nil diff --git a/internal/command/system_features.go b/internal/command/system_features.go new file mode 100644 index 000000000..4642c25ab --- /dev/null +++ b/internal/command/system_features.go @@ -0,0 +1,56 @@ +package command + +import ( + "context" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/repository/feature/feature_v2" + "github.com/zitadel/zitadel/internal/zerrors" +) + +type SystemFeatures struct { + LoginDefaultOrg *bool + TriggerIntrospectionProjections *bool + LegacyIntrospection *bool +} + +func (m *SystemFeatures) isEmpty() bool { + return m.LoginDefaultOrg == nil && + m.TriggerIntrospectionProjections == nil && + m.LegacyIntrospection == nil +} + +func (c *Commands) SetSystemFeatures(ctx context.Context, f *SystemFeatures) (*domain.ObjectDetails, error) { + if f.isEmpty() { + return nil, zerrors.ThrowInternal(nil, "COMMAND-Oop8a", "Errors.NoChangesFound") + } + wm := NewSystemFeaturesWriteModel() + if err := c.eventstore.FilterToQueryReducer(ctx, wm); err != nil { + return nil, err + } + cmds := wm.setCommands(ctx, f) + if len(cmds) == 0 { + return writeModelToObjectDetails(wm.WriteModel), nil + } + events, err := c.eventstore.Push(ctx, cmds...) + if err != nil { + return nil, err + } + return pushedEventsToObjectDetails(events), nil +} + +func (c *Commands) ResetSystemFeatures(ctx context.Context) (*domain.ObjectDetails, error) { + wm := NewSystemFeaturesWriteModel() + if err := c.eventstore.FilterToQueryReducer(ctx, wm); err != nil { + return nil, err + } + if wm.isEmpty() { + return writeModelToObjectDetails(wm.WriteModel), nil + } + aggregate := feature_v2.NewAggregate("SYSTEM", "SYSTEM") + events, err := c.eventstore.Push(ctx, feature_v2.NewResetEvent(ctx, aggregate, feature_v2.SystemResetEventType)) + if err != nil { + return nil, err + } + return pushedEventsToObjectDetails(events), nil +} diff --git a/internal/command/system_features_model.go b/internal/command/system_features_model.go new file mode 100644 index 000000000..91a29044c --- /dev/null +++ b/internal/command/system_features_model.go @@ -0,0 +1,94 @@ +package command + +import ( + "context" + + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/feature" + "github.com/zitadel/zitadel/internal/repository/feature/feature_v2" +) + +type SystemFeaturesWriteModel struct { + *eventstore.WriteModel + SystemFeatures +} + +func NewSystemFeaturesWriteModel() *SystemFeaturesWriteModel { + m := &SystemFeaturesWriteModel{ + WriteModel: &eventstore.WriteModel{ + AggregateID: "SYSTEM", + ResourceOwner: "SYSTEM", + }, + } + return m +} + +func (m *SystemFeaturesWriteModel) Reduce() (err error) { + for _, event := range m.Events { + switch e := event.(type) { + case *feature_v2.ResetEvent: + m.reduceReset() + case *feature_v2.SetEvent[bool]: + err = m.reduceBoolFeature(e) + } + if err != nil { + return err + } + } + return m.WriteModel.Reduce() +} + +func (m *SystemFeaturesWriteModel) Query() *eventstore.SearchQueryBuilder { + return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + AwaitOpenTransactions(). + AddQuery(). + AggregateTypes(feature_v2.AggregateType). + AggregateIDs(m.AggregateID). + EventTypes( + feature_v2.SystemResetEventType, + feature_v2.SystemLoginDefaultOrgEventType, + feature_v2.SystemTriggerIntrospectionProjectionsEventType, + feature_v2.SystemLegacyIntrospectionEventType, + ). + Builder().ResourceOwner(m.ResourceOwner) +} + +func (m *SystemFeaturesWriteModel) reduceReset() { + m.LoginDefaultOrg = nil + m.TriggerIntrospectionProjections = nil + m.LegacyIntrospection = nil +} + +func (m *SystemFeaturesWriteModel) reduceBoolFeature(event *feature_v2.SetEvent[bool]) error { + _, key, err := event.FeatureInfo() + if err != nil { + return err + } + switch key { + case feature.KeyUnspecified: + return nil + case feature.KeyLoginDefaultOrg: + m.LoginDefaultOrg = &event.Value + case feature.KeyTriggerIntrospectionProjections: + m.TriggerIntrospectionProjections = &event.Value + case feature.KeyLegacyIntrospection: + m.LegacyIntrospection = &event.Value + } + return nil +} + +func (wm *SystemFeaturesWriteModel) setCommands(ctx context.Context, f *SystemFeatures) []eventstore.Command { + aggregate := feature_v2.NewAggregate(wm.AggregateID, wm.ResourceOwner) + cmds := make([]eventstore.Command, 0, len(feature.KeyValues())-1) + cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.LoginDefaultOrg, f.LoginDefaultOrg, feature_v2.SystemLoginDefaultOrgEventType) + cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.TriggerIntrospectionProjections, f.TriggerIntrospectionProjections, feature_v2.SystemTriggerIntrospectionProjectionsEventType) + cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.LegacyIntrospection, f.LegacyIntrospection, feature_v2.SystemLegacyIntrospectionEventType) + return cmds +} + +func appendFeatureUpdate[T comparable](ctx context.Context, cmds []eventstore.Command, aggregate *feature_v2.Aggregate, oldValue, newValue *T, eventType eventstore.EventType) []eventstore.Command { + if newValue != nil && (oldValue == nil || *oldValue != *newValue) { + cmds = append(cmds, feature_v2.NewSetEvent[T](ctx, aggregate, eventType, *newValue)) + } + return cmds +} diff --git a/internal/command/system_features_test.go b/internal/command/system_features_test.go new file mode 100644 index 000000000..5307e8484 --- /dev/null +++ b/internal/command/system_features_test.go @@ -0,0 +1,290 @@ +package command + +import ( + "context" + "io" + "testing" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/feature/feature_v2" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func TestCommands_SetSystemFeatures(t *testing.T) { + aggregate := feature_v2.NewAggregate("SYSTEM", "SYSTEM") + + type args struct { + ctx context.Context + f *SystemFeatures + } + tests := []struct { + name string + eventstore func(*testing.T) *eventstore.Eventstore + args args + want *domain.ObjectDetails + wantErr error + }{ + { + name: "filter error", + eventstore: expectEventstore( + expectFilterError(io.ErrClosedPipe), + ), + args: args{context.Background(), &SystemFeatures{ + LoginDefaultOrg: gu.Ptr(true), + }}, + wantErr: io.ErrClosedPipe, + }, + { + name: "all nil, No Change", + eventstore: expectEventstore(), + args: args{context.Background(), &SystemFeatures{}}, + wantErr: zerrors.ThrowInternal(nil, "COMMAND-Oop8a", "Errors.NoChangesFound"), + }, + { + name: "set LoginDefaultOrg", + eventstore: expectEventstore( + expectFilter(), + expectPush( + feature_v2.NewSetEvent[bool]( + context.Background(), aggregate, + feature_v2.SystemLoginDefaultOrgEventType, true, + ), + ), + ), + args: args{context.Background(), &SystemFeatures{ + LoginDefaultOrg: gu.Ptr(true), + }}, + want: &domain.ObjectDetails{ + ResourceOwner: "SYSTEM", + }, + }, + { + name: "set TriggerIntrospectionProjections", + eventstore: expectEventstore( + expectFilter(), + expectPush( + feature_v2.NewSetEvent[bool]( + context.Background(), aggregate, + feature_v2.SystemTriggerIntrospectionProjectionsEventType, true, + ), + ), + ), + args: args{context.Background(), &SystemFeatures{ + TriggerIntrospectionProjections: gu.Ptr(true), + }}, + want: &domain.ObjectDetails{ + ResourceOwner: "SYSTEM", + }, + }, + { + name: "set LegacyIntrospection", + eventstore: expectEventstore( + expectFilter(), + expectPush( + feature_v2.NewSetEvent[bool]( + context.Background(), aggregate, + feature_v2.SystemLegacyIntrospectionEventType, true, + ), + ), + ), + args: args{context.Background(), &SystemFeatures{ + LegacyIntrospection: gu.Ptr(true), + }}, + want: &domain.ObjectDetails{ + ResourceOwner: "SYSTEM", + }, + }, + { + name: "push error", + eventstore: expectEventstore( + expectFilter(), + expectPushFailed(io.ErrClosedPipe, + feature_v2.NewSetEvent[bool]( + context.Background(), aggregate, + feature_v2.SystemLegacyIntrospectionEventType, true, + ), + ), + ), + args: args{context.Background(), &SystemFeatures{ + LegacyIntrospection: gu.Ptr(true), + }}, + wantErr: io.ErrClosedPipe, + }, + { + name: "set all", + eventstore: expectEventstore( + expectFilter(), + expectPush( + feature_v2.NewSetEvent[bool]( + context.Background(), aggregate, + feature_v2.SystemLoginDefaultOrgEventType, true, + ), + feature_v2.NewSetEvent[bool]( + context.Background(), aggregate, + feature_v2.SystemTriggerIntrospectionProjectionsEventType, false, + ), + feature_v2.NewSetEvent[bool]( + context.Background(), aggregate, + feature_v2.SystemLegacyIntrospectionEventType, true, + ), + ), + ), + args: args{context.Background(), &SystemFeatures{ + LoginDefaultOrg: gu.Ptr(true), + TriggerIntrospectionProjections: gu.Ptr(false), + LegacyIntrospection: gu.Ptr(true), + }}, + want: &domain.ObjectDetails{ + ResourceOwner: "SYSTEM", + }, + }, + { + name: "set only updated", + eventstore: expectEventstore( + // throw in some set events, reset and set again. + expectFilter( + eventFromEventPusher(feature_v2.NewSetEvent[bool]( + context.Background(), aggregate, + feature_v2.SystemLoginDefaultOrgEventType, true, + )), + eventFromEventPusher(feature_v2.NewSetEvent[bool]( + context.Background(), aggregate, + feature_v2.SystemTriggerIntrospectionProjectionsEventType, false, + )), + eventFromEventPusher(feature_v2.NewResetEvent( + context.Background(), aggregate, + feature_v2.SystemResetEventType, + )), + eventFromEventPusher(feature_v2.NewSetEvent[bool]( + context.Background(), aggregate, + feature_v2.SystemLoginDefaultOrgEventType, false, + )), + eventFromEventPusher(feature_v2.NewSetEvent[bool]( + context.Background(), aggregate, + feature_v2.SystemLegacyIntrospectionEventType, true, + )), + ), + expectPush( + feature_v2.NewSetEvent[bool]( + context.Background(), aggregate, + feature_v2.SystemLoginDefaultOrgEventType, true, + ), + feature_v2.NewSetEvent[bool]( + context.Background(), aggregate, + feature_v2.SystemTriggerIntrospectionProjectionsEventType, false, + ), + ), + ), + args: args{context.Background(), &SystemFeatures{ + LoginDefaultOrg: gu.Ptr(true), + TriggerIntrospectionProjections: gu.Ptr(false), + LegacyIntrospection: gu.Ptr(true), + }}, + want: &domain.ObjectDetails{ + ResourceOwner: "SYSTEM", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.eventstore(t), + } + got, err := c.SetSystemFeatures(tt.args.ctx, tt.args.f) + require.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestCommands_ResetSystemFeatures(t *testing.T) { + aggregate := feature_v2.NewAggregate("SYSTEM", "SYSTEM") + tests := []struct { + name string + eventstore func(*testing.T) *eventstore.Eventstore + want *domain.ObjectDetails + wantErr error + }{ + { + name: "filter error", + eventstore: expectEventstore( + expectFilterError(io.ErrClosedPipe), + ), + wantErr: io.ErrClosedPipe, + }, + { + name: "push error", + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher(feature_v2.NewSetEvent[bool]( + context.Background(), aggregate, + feature_v2.SystemLoginDefaultOrgEventType, true, + )), + ), + expectPushFailed(io.ErrClosedPipe, + feature_v2.NewResetEvent(context.Background(), aggregate, feature_v2.SystemResetEventType), + ), + ), + wantErr: io.ErrClosedPipe, + }, + { + name: "success", + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher(feature_v2.NewSetEvent[bool]( + context.Background(), aggregate, + feature_v2.SystemLoginDefaultOrgEventType, true, + )), + ), + expectPush( + feature_v2.NewResetEvent(context.Background(), aggregate, feature_v2.SystemResetEventType), + ), + ), + want: &domain.ObjectDetails{ + ResourceOwner: "SYSTEM", + }, + }, + { + name: "no change after previous reset", + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher(feature_v2.NewSetEvent[bool]( + context.Background(), aggregate, + feature_v2.SystemLoginDefaultOrgEventType, true, + )), + eventFromEventPusher(feature_v2.NewResetEvent( + context.Background(), aggregate, + feature_v2.SystemResetEventType, + )), + ), + ), + want: &domain.ObjectDetails{ + ResourceOwner: "SYSTEM", + }, + }, + { + name: "no change without previous events", + eventstore: expectEventstore( + expectFilter(), + ), + want: &domain.ObjectDetails{ + ResourceOwner: "SYSTEM", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.eventstore(t), + } + got, err := c.ResetSystemFeatures(context.Background()) + require.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/domain/feature_enumer.go b/internal/domain/feature_enumer.go deleted file mode 100644 index 008ec8720..000000000 --- a/internal/domain/feature_enumer.go +++ /dev/null @@ -1,78 +0,0 @@ -// Code generated by "enumer -type Feature"; DO NOT EDIT. - -package domain - -import ( - "fmt" - "strings" -) - -const _FeatureName = "FeatureUnspecifiedFeatureLoginDefaultOrg" - -var _FeatureIndex = [...]uint8{0, 18, 40} - -const _FeatureLowerName = "featureunspecifiedfeaturelogindefaultorg" - -func (i Feature) String() string { - if i < 0 || i >= Feature(len(_FeatureIndex)-1) { - return fmt.Sprintf("Feature(%d)", i) - } - return _FeatureName[_FeatureIndex[i]:_FeatureIndex[i+1]] -} - -// An "invalid array index" compiler error signifies that the constant values have changed. -// Re-run the stringer command to generate them again. -func _FeatureNoOp() { - var x [1]struct{} - _ = x[FeatureUnspecified-(0)] - _ = x[FeatureLoginDefaultOrg-(1)] -} - -var _FeatureValues = []Feature{FeatureUnspecified, FeatureLoginDefaultOrg} - -var _FeatureNameToValueMap = map[string]Feature{ - _FeatureName[0:18]: FeatureUnspecified, - _FeatureLowerName[0:18]: FeatureUnspecified, - _FeatureName[18:40]: FeatureLoginDefaultOrg, - _FeatureLowerName[18:40]: FeatureLoginDefaultOrg, -} - -var _FeatureNames = []string{ - _FeatureName[0:18], - _FeatureName[18:40], -} - -// FeatureString retrieves an enum value from the enum constants string name. -// Throws an error if the param is not part of the enum. -func FeatureString(s string) (Feature, error) { - if val, ok := _FeatureNameToValueMap[s]; ok { - return val, nil - } - - if val, ok := _FeatureNameToValueMap[strings.ToLower(s)]; ok { - return val, nil - } - return 0, fmt.Errorf("%s does not belong to Feature values", s) -} - -// FeatureValues returns all values of the enum -func FeatureValues() []Feature { - return _FeatureValues -} - -// FeatureStrings returns a slice of all String values of the enum -func FeatureStrings() []string { - strs := make([]string, len(_FeatureNames)) - copy(strs, _FeatureNames) - return strs -} - -// IsAFeature returns "true" if the value is listed in the enum definition. "false" otherwise -func (i Feature) IsAFeature() bool { - for _, v := range _FeatureValues { - if i == v { - return true - } - } - return false -} diff --git a/internal/domain/object.go b/internal/domain/object.go index 037843559..a9e90a6ca 100644 --- a/internal/domain/object.go +++ b/internal/domain/object.go @@ -1,6 +1,8 @@ package domain -import "time" +import ( + "time" +) type ObjectDetails struct { Sequence uint64 diff --git a/internal/eventstore/handler/v2/statement.go b/internal/eventstore/handler/v2/statement.go index 5d2665019..8d2898629 100644 --- a/internal/eventstore/handler/v2/statement.go +++ b/internal/eventstore/handler/v2/statement.go @@ -532,6 +532,12 @@ func NewIsNullCond(column string) Condition { } } +func NewIsNotNullCond(column string) Condition { + return func(string) (string, []any) { + return column + " IS NOT NULL", nil + } +} + // NewTextArrayContainsCond returns a Condition that checks if the column that stores an array of text contains the given value func NewTextArrayContainsCond(column string, value string) Condition { return func(param string) (string, []any) { diff --git a/internal/eventstore/read_model.go b/internal/eventstore/read_model.go index 6bf7dcf4f..10c84358e 100644 --- a/internal/eventstore/read_model.go +++ b/internal/eventstore/read_model.go @@ -44,7 +44,6 @@ func (rm *ReadModel) Reduce() error { rm.ChangeDate = rm.Events[len(rm.Events)-1].CreatedAt() rm.ProcessedSequence = rm.Events[len(rm.Events)-1].Sequence() // all events processed and not needed anymore - rm.Events = nil - rm.Events = []Event{} + rm.Events = rm.Events[0:0] return nil } diff --git a/internal/feature/feature.go b/internal/feature/feature.go new file mode 100644 index 000000000..0c277cb5a --- /dev/null +++ b/internal/feature/feature.go @@ -0,0 +1,30 @@ +package feature + +//go:generate enumer -type Key -transform snake -trimprefix Key +type Key int + +const ( + KeyUnspecified Key = iota + KeyLoginDefaultOrg + KeyTriggerIntrospectionProjections + KeyLegacyIntrospection +) + +//go:generate enumer -type Level -transform snake -trimprefix Level +type Level int + +const ( + LevelUnspecified Level = iota + LevelSystem + LevelInstance + LevelOrg + LevelProject + LevelApp + LevelUser +) + +type Features struct { + LoginDefaultOrg bool `json:"login_default_org,omitempty"` + TriggerIntrospectionProjections bool `json:"trigger_introspection_projections,omitempty"` + LegacyIntrospection bool `json:"legacy_introspection,omitempty"` +} diff --git a/internal/feature/feature_test.go b/internal/feature/feature_test.go new file mode 100644 index 000000000..70e3ec9ff --- /dev/null +++ b/internal/feature/feature_test.go @@ -0,0 +1,43 @@ +package feature + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestKey(t *testing.T) { + tests := []string{ + "unspecified", + "login_default_org", + "trigger_introspection_projections", + "legacy_introspection", + } + for _, want := range tests { + t.Run(want, func(t *testing.T) { + feature, err := KeyString(want) + require.NoError(t, err) + assert.Equal(t, want, feature.String()) + }) + } +} + +func TestLevel(t *testing.T) { + tests := []string{ + "unspecified", + "system", + "instance", + "org", + "project", + "app", + "user", + } + for _, want := range tests { + t.Run(want, func(t *testing.T) { + level, err := LevelString(want) + require.NoError(t, err) + assert.Equal(t, want, level.String()) + }) + } +} diff --git a/internal/feature/key_enumer.go b/internal/feature/key_enumer.go new file mode 100644 index 000000000..74e0af99d --- /dev/null +++ b/internal/feature/key_enumer.go @@ -0,0 +1,86 @@ +// Code generated by "enumer -type Key -transform snake -trimprefix Key"; DO NOT EDIT. + +package feature + +import ( + "fmt" + "strings" +) + +const _KeyName = "unspecifiedlogin_default_orgtrigger_introspection_projectionslegacy_introspection" + +var _KeyIndex = [...]uint8{0, 11, 28, 61, 81} + +const _KeyLowerName = "unspecifiedlogin_default_orgtrigger_introspection_projectionslegacy_introspection" + +func (i Key) String() string { + if i < 0 || i >= Key(len(_KeyIndex)-1) { + return fmt.Sprintf("Key(%d)", i) + } + return _KeyName[_KeyIndex[i]:_KeyIndex[i+1]] +} + +// An "invalid array index" compiler error signifies that the constant values have changed. +// Re-run the stringer command to generate them again. +func _KeyNoOp() { + var x [1]struct{} + _ = x[KeyUnspecified-(0)] + _ = x[KeyLoginDefaultOrg-(1)] + _ = x[KeyTriggerIntrospectionProjections-(2)] + _ = x[KeyLegacyIntrospection-(3)] +} + +var _KeyValues = []Key{KeyUnspecified, KeyLoginDefaultOrg, KeyTriggerIntrospectionProjections, KeyLegacyIntrospection} + +var _KeyNameToValueMap = map[string]Key{ + _KeyName[0:11]: KeyUnspecified, + _KeyLowerName[0:11]: KeyUnspecified, + _KeyName[11:28]: KeyLoginDefaultOrg, + _KeyLowerName[11:28]: KeyLoginDefaultOrg, + _KeyName[28:61]: KeyTriggerIntrospectionProjections, + _KeyLowerName[28:61]: KeyTriggerIntrospectionProjections, + _KeyName[61:81]: KeyLegacyIntrospection, + _KeyLowerName[61:81]: KeyLegacyIntrospection, +} + +var _KeyNames = []string{ + _KeyName[0:11], + _KeyName[11:28], + _KeyName[28:61], + _KeyName[61:81], +} + +// KeyString retrieves an enum value from the enum constants string name. +// Throws an error if the param is not part of the enum. +func KeyString(s string) (Key, error) { + if val, ok := _KeyNameToValueMap[s]; ok { + return val, nil + } + + if val, ok := _KeyNameToValueMap[strings.ToLower(s)]; ok { + return val, nil + } + return 0, fmt.Errorf("%s does not belong to Key values", s) +} + +// KeyValues returns all values of the enum +func KeyValues() []Key { + return _KeyValues +} + +// KeyStrings returns a slice of all String values of the enum +func KeyStrings() []string { + strs := make([]string, len(_KeyNames)) + copy(strs, _KeyNames) + return strs +} + +// IsAKey returns "true" if the value is listed in the enum definition. "false" otherwise +func (i Key) IsAKey() bool { + for _, v := range _KeyValues { + if i == v { + return true + } + } + return false +} diff --git a/internal/feature/level_enumer.go b/internal/feature/level_enumer.go new file mode 100644 index 000000000..99dca6115 --- /dev/null +++ b/internal/feature/level_enumer.go @@ -0,0 +1,98 @@ +// Code generated by "enumer -type Level -transform snake -trimprefix Level"; DO NOT EDIT. + +package feature + +import ( + "fmt" + "strings" +) + +const _LevelName = "unspecifiedsysteminstanceorgprojectappuser" + +var _LevelIndex = [...]uint8{0, 11, 17, 25, 28, 35, 38, 42} + +const _LevelLowerName = "unspecifiedsysteminstanceorgprojectappuser" + +func (i Level) String() string { + if i < 0 || i >= Level(len(_LevelIndex)-1) { + return fmt.Sprintf("Level(%d)", i) + } + return _LevelName[_LevelIndex[i]:_LevelIndex[i+1]] +} + +// An "invalid array index" compiler error signifies that the constant values have changed. +// Re-run the stringer command to generate them again. +func _LevelNoOp() { + var x [1]struct{} + _ = x[LevelUnspecified-(0)] + _ = x[LevelSystem-(1)] + _ = x[LevelInstance-(2)] + _ = x[LevelOrg-(3)] + _ = x[LevelProject-(4)] + _ = x[LevelApp-(5)] + _ = x[LevelUser-(6)] +} + +var _LevelValues = []Level{LevelUnspecified, LevelSystem, LevelInstance, LevelOrg, LevelProject, LevelApp, LevelUser} + +var _LevelNameToValueMap = map[string]Level{ + _LevelName[0:11]: LevelUnspecified, + _LevelLowerName[0:11]: LevelUnspecified, + _LevelName[11:17]: LevelSystem, + _LevelLowerName[11:17]: LevelSystem, + _LevelName[17:25]: LevelInstance, + _LevelLowerName[17:25]: LevelInstance, + _LevelName[25:28]: LevelOrg, + _LevelLowerName[25:28]: LevelOrg, + _LevelName[28:35]: LevelProject, + _LevelLowerName[28:35]: LevelProject, + _LevelName[35:38]: LevelApp, + _LevelLowerName[35:38]: LevelApp, + _LevelName[38:42]: LevelUser, + _LevelLowerName[38:42]: LevelUser, +} + +var _LevelNames = []string{ + _LevelName[0:11], + _LevelName[11:17], + _LevelName[17:25], + _LevelName[25:28], + _LevelName[28:35], + _LevelName[35:38], + _LevelName[38:42], +} + +// LevelString retrieves an enum value from the enum constants string name. +// Throws an error if the param is not part of the enum. +func LevelString(s string) (Level, error) { + if val, ok := _LevelNameToValueMap[s]; ok { + return val, nil + } + + if val, ok := _LevelNameToValueMap[strings.ToLower(s)]; ok { + return val, nil + } + return 0, fmt.Errorf("%s does not belong to Level values", s) +} + +// LevelValues returns all values of the enum +func LevelValues() []Level { + return _LevelValues +} + +// LevelStrings returns a slice of all String values of the enum +func LevelStrings() []string { + strs := make([]string, len(_LevelNames)) + copy(strs, _LevelNames) + return strs +} + +// IsALevel returns "true" if the value is listed in the enum definition. "false" otherwise +func (i Level) IsALevel() bool { + for _, v := range _LevelValues { + if i == v { + return true + } + } + return false +} diff --git a/internal/integration/client.go b/internal/integration/client.go index 3c928dd2b..6c5e8dd7a 100644 --- a/internal/integration/client.go +++ b/internal/integration/client.go @@ -27,6 +27,7 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/admin" "github.com/zitadel/zitadel/pkg/grpc/auth" execution "github.com/zitadel/zitadel/pkg/grpc/execution/v3alpha" + feature "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta" mgmt "github.com/zitadel/zitadel/pkg/grpc/management" object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2beta" @@ -49,6 +50,7 @@ type Client struct { OrgV2 organisation.OrganizationServiceClient System system.SystemServiceClient ExecutionV3 execution.ExecutionServiceClient + FeatureV2 feature.FeatureServiceClient } func newClient(cc *grpc.ClientConn) Client { @@ -63,6 +65,7 @@ func newClient(cc *grpc.ClientConn) Client { OrgV2: organisation.NewOrganizationServiceClient(cc), System: system.NewSystemServiceClient(cc), ExecutionV3: execution.NewExecutionServiceClient(cc), + FeatureV2: feature.NewFeatureServiceClient(cc), } } diff --git a/internal/query/converter.go b/internal/query/converter.go new file mode 100644 index 000000000..86ba17fa8 --- /dev/null +++ b/internal/query/converter.go @@ -0,0 +1,14 @@ +package query + +import ( + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" +) + +func readModelToObjectDetails(model *eventstore.ReadModel) *domain.ObjectDetails { + return &domain.ObjectDetails{ + Sequence: model.ProcessedSequence, + ResourceOwner: model.ResourceOwner, + EventDate: model.ChangeDate, + } +} diff --git a/internal/query/instance.go b/internal/query/instance.go index ba3bdfbce..ed6d173a7 100644 --- a/internal/query/instance.go +++ b/internal/query/instance.go @@ -3,6 +3,8 @@ package query import ( "context" "database/sql" + _ "embed" + "encoding/json" "errors" "strings" "time" @@ -15,6 +17,7 @@ import ( "github.com/zitadel/zitadel/internal/api/call" "github.com/zitadel/zitadel/internal/database" "github.com/zitadel/zitadel/internal/eventstore/handler/v2" + "github.com/zitadel/zitadel/internal/feature" "github.com/zitadel/zitadel/internal/query/projection" "github.com/zitadel/zitadel/internal/telemetry/tracing" "github.com/zitadel/zitadel/internal/zerrors" @@ -94,21 +97,12 @@ type Instance struct { Sequence uint64 Name string - DefaultOrgID string - IAMProjectID string - ConsoleID string - ConsoleAppID string - DefaultLang language.Tag - Domains []*InstanceDomain - host string - csp csp - block *bool - auditLogRetention *time.Duration -} - -type csp struct { - enabled bool - allowedOrigins database.TextArray[string] + DefaultOrgID string + IAMProjectID string + ConsoleID string + ConsoleAppID string + DefaultLang language.Tag + Domains []*InstanceDomain } type Instances struct { @@ -116,53 +110,6 @@ type Instances struct { Instances []*Instance } -func (i *Instance) InstanceID() string { - return i.ID -} - -func (i *Instance) ProjectID() string { - return i.IAMProjectID -} - -func (i *Instance) ConsoleClientID() string { - return i.ConsoleID -} - -func (i *Instance) ConsoleApplicationID() string { - return i.ConsoleAppID -} - -func (i *Instance) RequestedDomain() string { - return strings.Split(i.host, ":")[0] -} - -func (i *Instance) RequestedHost() string { - return i.host -} - -func (i *Instance) DefaultLanguage() language.Tag { - return i.DefaultLang -} - -func (i *Instance) DefaultOrganisationID() string { - return i.DefaultOrgID -} - -func (i *Instance) SecurityPolicyAllowedOrigins() []string { - if !i.csp.enabled { - return nil - } - return i.csp.allowedOrigins -} - -func (i *Instance) Block() *bool { - return i.block -} - -func (i *Instance) AuditLogRetention() *time.Duration { - return i.auditLogRetention -} - type InstanceSearchQueries struct { SearchRequest Queries []SearchQuery @@ -224,7 +171,7 @@ func (q *Queries) Instance(ctx context.Context, shouldTriggerBulk bool) (instanc traceSpan.EndWithError(err) } - stmt, scan := prepareInstanceDomainQuery(ctx, q.client, authz.GetInstance(ctx).RequestedDomain()) + stmt, scan := prepareInstanceDomainQuery(ctx, q.client) query, args, err := stmt.Where(sq.Eq{ InstanceColumnID.identifier(): authz.GetInstance(ctx).InstanceID(), }).ToSql() @@ -239,28 +186,34 @@ func (q *Queries) Instance(ctx context.Context, shouldTriggerBulk bool) (instanc return instance, err } -func (q *Queries) InstanceByHost(ctx context.Context, host string) (instance authz.Instance, err error) { +var ( + //go:embed instance_by_domain.sql + instanceByDomainQuery string + + //go:embed instance_by_id.sql + instanceByIDQuery string +) + +func (q *Queries) InstanceByHost(ctx context.Context, host string) (_ authz.Instance, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - stmt, scan := prepareAuthzInstanceQuery(ctx, q.client, host) - host = strings.Split(host, ":")[0] //remove possible port - query, args, err := stmt.Where(sq.Eq{ - InstanceDomainDomainCol.identifier(): host, - }).ToSql() - if err != nil { - return nil, zerrors.ThrowInternal(err, "QUERY-SAfg2", "Errors.Query.SQLStatement") - } - - err = q.client.QueryContext(ctx, func(rows *sql.Rows) error { - instance, err = scan(rows) - return err - }, query, args...) + domain := strings.Split(host, ":")[0] // remove possible port + instance, scan := scanAuthzInstance(host, domain) + err = q.client.QueryRowContext(ctx, scan, instanceByDomainQuery, domain) + logging.OnError(err).WithField("host", host).WithField("domain", domain).Warn("instance by host") return instance, err } func (q *Queries) InstanceByID(ctx context.Context) (_ authz.Instance, err error) { - return q.Instance(ctx, true) + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + instanceID := authz.GetInstance(ctx).InstanceID() + instance, scan := scanAuthzInstance("", "") + err = q.client.QueryRowContext(ctx, scan, instanceByIDQuery, instanceID) + logging.OnError(err).WithField("instance_id", instanceID).Warn("instance by ID") + return instance, err } func (q *Queries) GetDefaultLanguage(ctx context.Context) language.Tag { @@ -268,48 +221,7 @@ func (q *Queries) GetDefaultLanguage(ctx context.Context) language.Tag { if err != nil { return language.Und } - return instance.DefaultLanguage() -} - -func prepareInstanceQuery(ctx context.Context, db prepareDatabase, host string) (sq.SelectBuilder, func(*sql.Row) (*Instance, error)) { - return sq.Select( - InstanceColumnID.identifier(), - InstanceColumnCreationDate.identifier(), - InstanceColumnChangeDate.identifier(), - InstanceColumnSequence.identifier(), - InstanceColumnDefaultOrgID.identifier(), - InstanceColumnProjectID.identifier(), - InstanceColumnConsoleID.identifier(), - InstanceColumnConsoleAppID.identifier(), - InstanceColumnDefaultLanguage.identifier(), - ). - From(instanceTable.identifier() + db.Timetravel(call.Took(ctx))). - PlaceholderFormat(sq.Dollar), - func(row *sql.Row) (*Instance, error) { - var ( - instance = &Instance{host: host} - lang = "" - ) - err := row.Scan( - &instance.ID, - &instance.CreationDate, - &instance.ChangeDate, - &instance.Sequence, - &instance.DefaultOrgID, - &instance.IAMProjectID, - &instance.ConsoleID, - &instance.ConsoleAppID, - &lang, - ) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, zerrors.ThrowNotFound(err, "QUERY-5m09s", "Errors.IAM.NotFound") - } - return nil, zerrors.ThrowInternal(err, "QUERY-3j9sf", "Errors.Internal") - } - instance.DefaultLang = language.Make(lang) - return instance, nil - } + return instance.DefaultLang } func prepareInstancesQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(sq.SelectBuilder) sq.SelectBuilder, func(*sql.Rows) (*Instances, error)) { @@ -417,7 +329,7 @@ func prepareInstancesQuery(ctx context.Context, db prepareDatabase) (sq.SelectBu } } -func prepareInstanceDomainQuery(ctx context.Context, db prepareDatabase, host string) (sq.SelectBuilder, func(*sql.Rows) (*Instance, error)) { +func prepareInstanceDomainQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Rows) (*Instance, error)) { return sq.Select( InstanceColumnID.identifier(), InstanceColumnCreationDate.identifier(), @@ -441,7 +353,6 @@ func prepareInstanceDomainQuery(ctx context.Context, db prepareDatabase, host st PlaceholderFormat(sq.Dollar), func(rows *sql.Rows) (*Instance, error) { instance := &Instance{ - host: host, Domains: make([]*InstanceDomain, 0), } lang := "" @@ -499,104 +410,123 @@ func prepareInstanceDomainQuery(ctx context.Context, db prepareDatabase, host st } } -func prepareAuthzInstanceQuery(ctx context.Context, db prepareDatabase, host string) (sq.SelectBuilder, func(*sql.Rows) (*Instance, error)) { - return sq.Select( - InstanceColumnID.identifier(), - InstanceColumnCreationDate.identifier(), - InstanceColumnChangeDate.identifier(), - InstanceColumnSequence.identifier(), - InstanceColumnName.identifier(), - InstanceColumnDefaultOrgID.identifier(), - InstanceColumnProjectID.identifier(), - InstanceColumnConsoleID.identifier(), - InstanceColumnConsoleAppID.identifier(), - InstanceColumnDefaultLanguage.identifier(), - InstanceDomainDomainCol.identifier(), - InstanceDomainIsPrimaryCol.identifier(), - InstanceDomainIsGeneratedCol.identifier(), - InstanceDomainCreationDateCol.identifier(), - InstanceDomainChangeDateCol.identifier(), - InstanceDomainSequenceCol.identifier(), - SecurityPolicyColumnEnabled.identifier(), - SecurityPolicyColumnAllowedOrigins.identifier(), - LimitsColumnAuditLogRetention.identifier(), - LimitsColumnBlock.identifier(), - ). - From(instanceTable.identifier()). - LeftJoin(join(InstanceDomainInstanceIDCol, InstanceColumnID)). - LeftJoin(join(SecurityPolicyColumnInstanceID, InstanceColumnID)). - LeftJoin(join(LimitsColumnInstanceID, InstanceColumnID) + db.Timetravel(call.Took(ctx))). - PlaceholderFormat(sq.Dollar), - func(rows *sql.Rows) (*Instance, error) { - instance := &Instance{ - host: host, - Domains: make([]*InstanceDomain, 0), - } - lang := "" - for rows.Next() { - var ( - domain sql.NullString - isPrimary sql.NullBool - isGenerated sql.NullBool - changeDate sql.NullTime - creationDate sql.NullTime - sequence sql.NullInt64 - securityPolicyEnabled sql.NullBool - auditLogRetention database.NullDuration - block sql.NullBool - ) - err := rows.Scan( - &instance.ID, - &instance.CreationDate, - &instance.ChangeDate, - &instance.Sequence, - &instance.Name, - &instance.DefaultOrgID, - &instance.IAMProjectID, - &instance.ConsoleID, - &instance.ConsoleAppID, - &lang, - &domain, - &isPrimary, - &isGenerated, - &changeDate, - &creationDate, - &sequence, - &securityPolicyEnabled, - &instance.csp.allowedOrigins, - &auditLogRetention, - &block, - ) - if err != nil { - return nil, zerrors.ThrowInternal(err, "QUERY-d3fas", "Errors.Internal") - } - if !domain.Valid { - continue - } - instance.Domains = append(instance.Domains, &InstanceDomain{ - CreationDate: creationDate.Time, - ChangeDate: changeDate.Time, - Sequence: uint64(sequence.Int64), - Domain: domain.String, - IsPrimary: isPrimary.Bool, - IsGenerated: isGenerated.Bool, - InstanceID: instance.ID, - }) - if auditLogRetention.Valid { - instance.auditLogRetention = &auditLogRetention.Duration - } - if block.Valid { - instance.block = &block.Bool - } - instance.csp.enabled = securityPolicyEnabled.Bool - } - if instance.ID == "" { - return nil, zerrors.ThrowNotFound(nil, "QUERY-1kIjX", "Errors.IAM.NotFound") - } - instance.DefaultLang = language.Make(lang) - if err := rows.Close(); err != nil { - return nil, zerrors.ThrowInternal(err, "QUERY-Dfbe2", "Errors.Query.CloseRows") - } - return instance, nil +type authzInstance struct { + id string + iamProjectID string + consoleID string + consoleAppID string + host string + domain string + defaultLang language.Tag + defaultOrgID string + csp csp + block *bool + auditLogRetention *time.Duration + features feature.Features +} + +type csp struct { + enabled bool + allowedOrigins database.TextArray[string] +} + +func (i *authzInstance) InstanceID() string { + return i.id +} + +func (i *authzInstance) ProjectID() string { + return i.iamProjectID +} + +func (i *authzInstance) ConsoleClientID() string { + return i.consoleID +} + +func (i *authzInstance) ConsoleApplicationID() string { + return i.consoleAppID +} + +func (i *authzInstance) RequestedDomain() string { + return strings.Split(i.host, ":")[0] +} + +func (i *authzInstance) RequestedHost() string { + return i.host +} + +func (i *authzInstance) DefaultLanguage() language.Tag { + return i.defaultLang +} + +func (i *authzInstance) DefaultOrganisationID() string { + return i.defaultOrgID +} + +func (i *authzInstance) SecurityPolicyAllowedOrigins() []string { + if !i.csp.enabled { + return nil + } + return i.csp.allowedOrigins +} + +func (i *authzInstance) Block() *bool { + return i.block +} + +func (i *authzInstance) AuditLogRetention() *time.Duration { + return i.auditLogRetention +} + +func (i *authzInstance) Features() feature.Features { + return i.features +} + +func scanAuthzInstance(host, domain string) (*authzInstance, func(row *sql.Row) error) { + instance := &authzInstance{ + host: host, + domain: domain, + } + return instance, func(row *sql.Row) error { + var ( + lang string + securityPolicyEnabled sql.NullBool + auditLogRetention database.NullDuration + block sql.NullBool + features []byte + ) + err := row.Scan( + &instance.id, + &instance.defaultOrgID, + &instance.iamProjectID, + &instance.consoleID, + &instance.consoleAppID, + &lang, + &securityPolicyEnabled, + &instance.csp.allowedOrigins, + &auditLogRetention, + &block, + &features, + ) + if errors.Is(err, sql.ErrNoRows) { + return zerrors.ThrowNotFound(nil, "QUERY-1kIjX", "Errors.IAM.NotFound") } + if err != nil { + return zerrors.ThrowInternal(err, "QUERY-d3fas", "Errors.Internal") + } + instance.defaultLang = language.Make(lang) + if auditLogRetention.Valid { + instance.auditLogRetention = &auditLogRetention.Duration + } + if block.Valid { + instance.block = &block.Bool + } + instance.csp.enabled = securityPolicyEnabled.Bool + if len(features) == 0 { + return nil + } + if err = json.Unmarshal(features, &instance.features); err != nil { + return zerrors.ThrowInternal(err, "QUERY-Po8ki", "Errors.Internal") + } + return nil + } } diff --git a/internal/query/instance_by_domain.sql b/internal/query/instance_by_domain.sql new file mode 100644 index 000000000..b7fc8c3fe --- /dev/null +++ b/internal/query/instance_by_domain.sql @@ -0,0 +1,30 @@ +with domain as ( + select instance_id from projections.instance_domains + where domain = $1 +), features as ( + select instance_id, json_object_agg( + coalesce(i.key, s.key), + coalesce(i.value, s.value) + ) features + from domain d + cross join projections.system_features s + full outer join projections.instance_features i using (key, instance_id) + group by instance_id +) +select + i.id, + i.default_org_id, + i.iam_project_id, + i.console_client_id, + i.console_app_id, + i.default_language, + s.enabled, + s.origins, + l.audit_log_retention, + l.block, + f.features +from domain d +join projections.instances i on i.id = d.instance_id +left join projections.security_policies s on i.id = s.instance_id +left join projections.limits l on i.id = l.instance_id +left join features f on i.id = f.instance_id; diff --git a/internal/query/instance_by_id.sql b/internal/query/instance_by_id.sql new file mode 100644 index 000000000..b316c4c66 --- /dev/null +++ b/internal/query/instance_by_id.sql @@ -0,0 +1,27 @@ +with features as ( + select instance_id, json_object_agg( + coalesce(i.key, s.key), + coalesce(i.value, s.value) + ) features + from (select $1::text instance_id) x + cross join projections.system_features s + full outer join projections.instance_features i using (key, instance_id) + group by instance_id +) +select + i.id, + i.default_org_id, + i.iam_project_id, + i.console_client_id, + i.console_app_id, + i.default_language, + s.enabled, + s.origins, + l.audit_log_retention, + l.block, + f.features +from projections.instances i +left join projections.security_policies s on i.id = s.instance_id +left join projections.limits l on i.id = l.instance_id +left join features f on i.id = f.instance_id +where i.id = $1; diff --git a/internal/query/instance_features.go b/internal/query/instance_features.go new file mode 100644 index 000000000..b447df4a8 --- /dev/null +++ b/internal/query/instance_features.go @@ -0,0 +1,30 @@ +package query + +import ( + "context" + + "github.com/zitadel/zitadel/internal/domain" +) + +type InstanceFeatures struct { + Details *domain.ObjectDetails + LoginDefaultOrg FeatureSource[bool] + TriggerIntrospectionProjections FeatureSource[bool] + LegacyIntrospection FeatureSource[bool] +} + +func (q *Queries) GetInstanceFeatures(ctx context.Context, cascade bool) (_ *InstanceFeatures, err error) { + var system *SystemFeatures + if cascade { + system, err = q.GetSystemFeatures(ctx) + if err != nil { + return nil, err + } + } + m := NewInstanceFeaturesReadModel(ctx, system) + if err = q.eventstore.FilterToQueryReducer(ctx, m); err != nil { + return nil, err + } + m.instance.Details = readModelToObjectDetails(m.ReadModel) + return m.instance, nil +} diff --git a/internal/query/instance_features_model.go b/internal/query/instance_features_model.go new file mode 100644 index 000000000..ff23b82af --- /dev/null +++ b/internal/query/instance_features_model.go @@ -0,0 +1,109 @@ +package query + +import ( + "context" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/feature" + feature_v1 "github.com/zitadel/zitadel/internal/repository/feature" + "github.com/zitadel/zitadel/internal/repository/feature/feature_v2" +) + +type InstanceFeaturesReadModel struct { + *eventstore.ReadModel + system *SystemFeatures + instance *InstanceFeatures +} + +func NewInstanceFeaturesReadModel(ctx context.Context, system *SystemFeatures) *InstanceFeaturesReadModel { + instanceID := authz.GetInstance(ctx).InstanceID() + m := &InstanceFeaturesReadModel{ + ReadModel: &eventstore.ReadModel{ + AggregateID: instanceID, + ResourceOwner: instanceID, + }, + instance: new(InstanceFeatures), + system: system, + } + m.populateFromSystem() + return m +} + +func (m *InstanceFeaturesReadModel) Reduce() (err error) { + for _, event := range m.Events { + switch e := event.(type) { + case *feature_v2.ResetEvent: + m.reduceReset() + case *feature_v1.SetEvent[feature_v1.Boolean]: + err = m.reduceBoolFeature( + feature_v1.DefaultLoginInstanceEventToV2(e), + ) + case *feature_v2.SetEvent[bool]: + err = m.reduceBoolFeature(e) + } + if err != nil { + return err + } + } + return m.ReadModel.Reduce() +} + +func (m *InstanceFeaturesReadModel) Query() *eventstore.SearchQueryBuilder { + return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + AwaitOpenTransactions(). + AddQuery(). + AggregateTypes(feature_v2.AggregateType). + AggregateIDs(m.AggregateID). + EventTypes( + feature_v1.DefaultLoginInstanceEventType, + feature_v2.InstanceResetEventType, + feature_v2.InstanceLoginDefaultOrgEventType, + feature_v2.InstanceTriggerIntrospectionProjectionsEventType, + feature_v2.InstanceLegacyIntrospectionEventType, + ). + Builder().ResourceOwner(m.ResourceOwner) +} + +func (m *InstanceFeaturesReadModel) reduceReset() { + if m.populateFromSystem() { + return + } + m.instance.LoginDefaultOrg = FeatureSource[bool]{} + m.instance.TriggerIntrospectionProjections = FeatureSource[bool]{} + m.instance.LegacyIntrospection = FeatureSource[bool]{} +} + +func (m *InstanceFeaturesReadModel) populateFromSystem() bool { + if m.system == nil { + return false + } + m.instance.LoginDefaultOrg = m.system.LoginDefaultOrg + m.instance.TriggerIntrospectionProjections = m.system.TriggerIntrospectionProjections + m.instance.LegacyIntrospection = m.system.LegacyIntrospection + return true +} + +func (m *InstanceFeaturesReadModel) reduceBoolFeature(event *feature_v2.SetEvent[bool]) error { + level, key, err := event.FeatureInfo() + if err != nil { + return err + } + var dst *FeatureSource[bool] + + switch key { + case feature.KeyUnspecified: + return nil + case feature.KeyLoginDefaultOrg: + dst = &m.instance.LoginDefaultOrg + case feature.KeyTriggerIntrospectionProjections: + dst = &m.instance.TriggerIntrospectionProjections + case feature.KeyLegacyIntrospection: + dst = &m.instance.LegacyIntrospection + } + *dst = FeatureSource[bool]{ + Level: level, + Value: event.Value, + } + return nil +} diff --git a/internal/query/instance_features_test.go b/internal/query/instance_features_test.go new file mode 100644 index 000000000..142c72d62 --- /dev/null +++ b/internal/query/instance_features_test.go @@ -0,0 +1,230 @@ +package query + +import ( + "context" + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/feature" + "github.com/zitadel/zitadel/internal/repository/feature/feature_v2" +) + +func TestQueries_GetInstanceFeatures(t *testing.T) { + ctx := authz.WithInstanceID(context.Background(), "instance1") + aggregate := feature_v2.NewAggregate("instance1", "instance1") + + type args struct { + cascade bool + } + tests := []struct { + name string + eventstore func(*testing.T) *eventstore.Eventstore + args args + want *InstanceFeatures + wantErr error + }{ + { + name: "filter error", + args: args{false}, + eventstore: expectEventstore( + expectFilterError(io.ErrClosedPipe), + ), + wantErr: io.ErrClosedPipe, + }, + { + name: "system filter error cascaded", + args: args{true}, + eventstore: expectEventstore( + expectFilterError(io.ErrClosedPipe), + ), + wantErr: io.ErrClosedPipe, + }, + { + name: "no features set, not cascaded", + eventstore: expectEventstore( + expectFilter(), + ), + want: &InstanceFeatures{ + Details: &domain.ObjectDetails{ + ResourceOwner: "instance1", + }, + }, + }, + { + name: "no features set, cascaded", + eventstore: expectEventstore( + expectFilter(), + expectFilter(), + ), + args: args{true}, + want: &InstanceFeatures{ + Details: &domain.ObjectDetails{ + ResourceOwner: "instance1", + }, + LoginDefaultOrg: FeatureSource[bool]{ + Level: feature.LevelUnspecified, + Value: false, + }, + TriggerIntrospectionProjections: FeatureSource[bool]{ + Level: feature.LevelUnspecified, + Value: false, + }, + LegacyIntrospection: FeatureSource[bool]{ + Level: feature.LevelUnspecified, + Value: false, + }, + }, + }, + { + name: "all features set", + eventstore: expectEventstore( + expectFilter(eventFromEventPusher(feature_v2.NewSetEvent[bool]( + context.Background(), aggregate, + feature_v2.SystemLoginDefaultOrgEventType, true, + ))), + expectFilter( + eventFromEventPusher(feature_v2.NewSetEvent[bool]( + ctx, aggregate, + feature_v2.InstanceLoginDefaultOrgEventType, false, + )), + eventFromEventPusher(feature_v2.NewSetEvent[bool]( + ctx, aggregate, + feature_v2.InstanceTriggerIntrospectionProjectionsEventType, true, + )), + eventFromEventPusher(feature_v2.NewSetEvent[bool]( + ctx, aggregate, + feature_v2.InstanceLegacyIntrospectionEventType, false, + )), + ), + ), + args: args{true}, + want: &InstanceFeatures{ + Details: &domain.ObjectDetails{ + ResourceOwner: "instance1", + }, + LoginDefaultOrg: FeatureSource[bool]{ + Level: feature.LevelInstance, + Value: false, + }, + TriggerIntrospectionProjections: FeatureSource[bool]{ + Level: feature.LevelInstance, + Value: true, + }, + LegacyIntrospection: FeatureSource[bool]{ + Level: feature.LevelInstance, + Value: false, + }, + }, + }, + { + name: "all features set, reset, set some feature, cascaded", + eventstore: expectEventstore( + expectFilter(eventFromEventPusher(feature_v2.NewSetEvent[bool]( + context.Background(), aggregate, + feature_v2.SystemLoginDefaultOrgEventType, true, + ))), + expectFilter( + eventFromEventPusher(feature_v2.NewSetEvent[bool]( + ctx, aggregate, + feature_v2.InstanceLoginDefaultOrgEventType, false, + )), + eventFromEventPusher(feature_v2.NewSetEvent[bool]( + ctx, aggregate, + feature_v2.InstanceTriggerIntrospectionProjectionsEventType, true, + )), + eventFromEventPusher(feature_v2.NewSetEvent[bool]( + ctx, aggregate, + feature_v2.InstanceLegacyIntrospectionEventType, false, + )), + eventFromEventPusher(feature_v2.NewResetEvent( + ctx, aggregate, + feature_v2.InstanceResetEventType, + )), + eventFromEventPusher(feature_v2.NewSetEvent[bool]( + ctx, aggregate, + feature_v2.InstanceTriggerIntrospectionProjectionsEventType, true, + )), + ), + ), + args: args{true}, + want: &InstanceFeatures{ + Details: &domain.ObjectDetails{ + ResourceOwner: "instance1", + }, + LoginDefaultOrg: FeatureSource[bool]{ + Level: feature.LevelSystem, + Value: true, + }, + TriggerIntrospectionProjections: FeatureSource[bool]{ + Level: feature.LevelInstance, + Value: true, + }, + LegacyIntrospection: FeatureSource[bool]{ + Level: feature.LevelUnspecified, + Value: false, + }, + }, + }, + { + name: "all features set, reset, set some feature, not cascaded", + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher(feature_v2.NewSetEvent[bool]( + ctx, aggregate, + feature_v2.InstanceLoginDefaultOrgEventType, false, + )), + eventFromEventPusher(feature_v2.NewSetEvent[bool]( + ctx, aggregate, + feature_v2.InstanceTriggerIntrospectionProjectionsEventType, true, + )), + eventFromEventPusher(feature_v2.NewSetEvent[bool]( + ctx, aggregate, + feature_v2.InstanceLegacyIntrospectionEventType, false, + )), + eventFromEventPusher(feature_v2.NewResetEvent( + ctx, aggregate, + feature_v2.InstanceResetEventType, + )), + eventFromEventPusher(feature_v2.NewSetEvent[bool]( + ctx, aggregate, + feature_v2.InstanceTriggerIntrospectionProjectionsEventType, true, + )), + ), + ), + args: args{false}, + want: &InstanceFeatures{ + Details: &domain.ObjectDetails{ + ResourceOwner: "instance1", + }, + LoginDefaultOrg: FeatureSource[bool]{ + Level: feature.LevelUnspecified, + Value: false, + }, + TriggerIntrospectionProjections: FeatureSource[bool]{ + Level: feature.LevelInstance, + Value: true, + }, + LegacyIntrospection: FeatureSource[bool]{ + Level: feature.LevelUnspecified, + Value: false, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + q := &Queries{ + eventstore: tt.eventstore(t), + } + got, err := q.GetInstanceFeatures(ctx, tt.args.cascade) + require.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/query/instance_test.go b/internal/query/instance_test.go index 03b9937d5..8d9c7e159 100644 --- a/internal/query/instance_test.go +++ b/internal/query/instance_test.go @@ -12,33 +12,9 @@ import ( sq "github.com/Masterminds/squirrel" "golang.org/x/text/language" - - "github.com/zitadel/zitadel/internal/zerrors" ) var ( - instanceQuery = `SELECT projections.instances.id,` + - ` projections.instances.creation_date,` + - ` projections.instances.change_date,` + - ` projections.instances.sequence,` + - ` projections.instances.default_org_id,` + - ` projections.instances.iam_project_id,` + - ` projections.instances.console_client_id,` + - ` projections.instances.console_app_id,` + - ` projections.instances.default_language` + - ` FROM projections.instances` + - ` AS OF SYSTEM TIME '-1 ms'` - instanceCols = []string{ - "id", - "creation_date", - "change_date", - "sequence", - "default_org_id", - "iam_project_id", - "console_client_id", - "console_app_id", - "default_language", - } instancesQuery = `SELECT f.count, f.id,` + ` projections.instances.creation_date,` + ` projections.instances.change_date,` + @@ -93,76 +69,6 @@ func Test_InstancePrepares(t *testing.T) { want want object interface{} }{ - { - name: "prepareInstanceQuery no result", - additionalArgs: []reflect.Value{reflect.ValueOf("")}, - prepare: prepareInstanceQuery, - want: want{ - sqlExpectations: mockQueriesScanErr( - regexp.QuoteMeta(instanceQuery), - nil, - nil, - ), - err: func(err error) (error, bool) { - if !zerrors.IsNotFound(err) { - return fmt.Errorf("err should be zitadel.NotFoundError got: %w", err), false - } - return nil, true - }, - }, - object: (*Instance)(nil), - }, - { - name: "prepareInstanceQuery found", - additionalArgs: []reflect.Value{reflect.ValueOf("")}, - prepare: prepareInstanceQuery, - want: want{ - sqlExpectations: mockQuery( - regexp.QuoteMeta(instanceQuery), - instanceCols, - []driver.Value{ - "id", - testNow, - testNow, - uint64(20211108), - "global-org-id", - "project-id", - "client-id", - "app-id", - "en", - }, - ), - }, - object: &Instance{ - ID: "id", - CreationDate: testNow, - ChangeDate: testNow, - Sequence: 20211108, - DefaultOrgID: "global-org-id", - IAMProjectID: "project-id", - ConsoleID: "client-id", - ConsoleAppID: "app-id", - DefaultLang: language.English, - }, - }, - { - name: "prepareInstanceQuery sql err", - additionalArgs: []reflect.Value{reflect.ValueOf("")}, - prepare: prepareInstanceQuery, - want: want{ - sqlExpectations: mockQueryErr( - regexp.QuoteMeta(instanceQuery), - sql.ErrConnDone, - ), - err: func(err error) (error, bool) { - if !errors.Is(err, sql.ErrConnDone) { - return fmt.Errorf("err should be sql.ErrConnDone got: %w", err), false - } - return nil, true - }, - }, - object: (*Instance)(nil), - }, { name: "prepareInstancesQuery no result", prepare: func(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Rows) (*Instances, error)) { diff --git a/internal/query/projection/instance_features.go b/internal/query/projection/instance_features.go new file mode 100644 index 000000000..bf7f0d041 --- /dev/null +++ b/internal/query/projection/instance_features.go @@ -0,0 +1,120 @@ +package projection + +import ( + "context" + + "github.com/zitadel/zitadel/internal/eventstore" + old_handler "github.com/zitadel/zitadel/internal/eventstore/handler" + "github.com/zitadel/zitadel/internal/eventstore/handler/v2" + feature_v1 "github.com/zitadel/zitadel/internal/repository/feature" + "github.com/zitadel/zitadel/internal/repository/feature/feature_v2" + "github.com/zitadel/zitadel/internal/repository/instance" + "github.com/zitadel/zitadel/internal/zerrors" +) + +const ( + InstanceFeatureTable = "projections.instance_features" + + InstanceFeatureInstanceIDCol = "instance_id" + InstanceFeatureKeyCol = "key" + InstanceFeatureCreationDateCol = "creation_date" + InstanceFeatureChangeDateCol = "change_date" + InstanceFeatureSequenceCol = "sequence" + InstanceFeatureValueCol = "value" +) + +type instanceFeatureProjection struct{} + +func newInstanceFeatureProjection(ctx context.Context, config handler.Config) *handler.Handler { + return handler.NewHandler(ctx, &config, new(instanceFeatureProjection)) +} + +func (*instanceFeatureProjection) Name() string { + return InstanceFeatureTable +} + +func (*instanceFeatureProjection) Init() *old_handler.Check { + return handler.NewTableCheck(handler.NewTable( + []*handler.InitColumn{ + handler.NewColumn(InstanceFeatureInstanceIDCol, handler.ColumnTypeText), + handler.NewColumn(InstanceFeatureKeyCol, handler.ColumnTypeText), + handler.NewColumn(InstanceFeatureCreationDateCol, handler.ColumnTypeTimestamp), + handler.NewColumn(InstanceFeatureChangeDateCol, handler.ColumnTypeTimestamp), + handler.NewColumn(InstanceFeatureSequenceCol, handler.ColumnTypeInt64), + handler.NewColumn(InstanceFeatureValueCol, handler.ColumnTypeJSONB), + }, + handler.NewPrimaryKey(InstanceFeatureInstanceIDCol, InstanceFeatureKeyCol), + )) +} + +func (*instanceFeatureProjection) Reducers() []handler.AggregateReducer { + return []handler.AggregateReducer{{ + Aggregate: feature_v2.AggregateType, + EventReducers: []handler.EventReducer{ + { + Event: feature_v1.DefaultLoginInstanceEventType, + Reduce: reduceSetDefaultLoginInstance_v1, + }, + { + Event: feature_v2.InstanceResetEventType, + Reduce: reduceInstanceResetFeatures, + }, + { + Event: feature_v2.InstanceLoginDefaultOrgEventType, + Reduce: reduceInstanceSetFeature[bool], + }, + { + Event: feature_v2.InstanceTriggerIntrospectionProjectionsEventType, + Reduce: reduceInstanceSetFeature[bool], + }, + { + Event: feature_v2.InstanceLegacyIntrospectionEventType, + Reduce: reduceInstanceSetFeature[bool], + }, + { + Event: instance.InstanceRemovedEventType, + Reduce: reduceInstanceRemovedHelper(InstanceDomainInstanceIDCol), + }, + }, + }} +} + +func reduceSetDefaultLoginInstance_v1(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*feature_v1.SetEvent[feature_v1.Boolean]) + if !ok { + return nil, zerrors.ThrowInvalidArgumentf(nil, "PROJE-in2Xo", "reduce.wrong.event.type %T", event) + } + return reduceInstanceSetFeature[bool]( + feature_v1.DefaultLoginInstanceEventToV2(e), + ) +} + +func reduceInstanceSetFeature[T any](event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*feature_v2.SetEvent[T]) + if !ok { + return nil, zerrors.ThrowInvalidArgumentf(nil, "PROJE-uPh8O", "reduce.wrong.event.type %T", event) + } + f, err := e.FeatureJSON() + if err != nil { + return nil, err + } + columns := []handler.Column{ + handler.NewCol(InstanceFeatureInstanceIDCol, e.Aggregate().ID), + handler.NewCol(InstanceFeatureKeyCol, f.Key.String()), + handler.NewCol(InstanceFeatureCreationDateCol, handler.OnlySetValueOnInsert(InstanceFeatureTable, e.CreationDate())), + handler.NewCol(InstanceFeatureChangeDateCol, e.CreationDate()), + handler.NewCol(InstanceFeatureSequenceCol, e.Sequence()), + handler.NewCol(InstanceFeatureValueCol, f.Value), + } + return handler.NewUpsertStatement(e, columns[0:2], columns), nil +} + +func reduceInstanceResetFeatures(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*feature_v2.ResetEvent) + if !ok { + return nil, zerrors.ThrowInvalidArgumentf(nil, "PROJE-roo6A", "reduce.wrong.event.type %T", event) + } + return handler.NewDeleteStatement(e, []handler.Condition{ + handler.NewCond(InstanceFeatureInstanceIDCol, e.Aggregate().ID), + }), nil +} diff --git a/internal/query/projection/instance_features_test.go b/internal/query/projection/instance_features_test.go new file mode 100644 index 000000000..f43233ae6 --- /dev/null +++ b/internal/query/projection/instance_features_test.go @@ -0,0 +1,152 @@ +package projection + +import ( + "testing" + + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/eventstore/handler/v2" + feature_v1 "github.com/zitadel/zitadel/internal/repository/feature" + "github.com/zitadel/zitadel/internal/repository/feature/feature_v2" + "github.com/zitadel/zitadel/internal/repository/instance" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func TestInstanceFeaturesProjection_reduces(t *testing.T) { + type args struct { + event func(t *testing.T) eventstore.Event + } + tests := []struct { + name string + args args + reduce func(event eventstore.Event) (*handler.Statement, error) + want wantReduce + }{ + { + name: "reduceInstanceSetFeature", + args: args{ + event: getEvent( + testEvent( + feature_v2.InstanceLegacyIntrospectionEventType, + feature_v2.AggregateType, + []byte(`{"value": true}`), + ), eventstore.GenericEventMapper[feature_v2.SetEvent[bool]]), + }, + reduce: reduceInstanceSetFeature[bool], + want: wantReduce{ + aggregateType: feature_v2.AggregateType, + sequence: 15, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "INSERT INTO projections.instance_features (instance_id, key, creation_date, change_date, sequence, value) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (instance_id, key) DO UPDATE SET (creation_date, change_date, sequence, value) = (projections.instance_features.creation_date, EXCLUDED.change_date, EXCLUDED.sequence, EXCLUDED.value)", + expectedArgs: []interface{}{ + "agg-id", + "legacy_introspection", + anyArg{}, + anyArg{}, + uint64(15), + []byte("true"), + }, + }, + }, + }, + }, + }, + { + name: "reduceSetDefaultLoginInstance_v1", + args: args{ + event: getEvent( + testEvent( + feature_v1.DefaultLoginInstanceEventType, + feature_v1.AggregateType, + []byte(`{"Value":{"Boolean":true}}`), + ), eventstore.GenericEventMapper[feature_v1.SetEvent[feature_v1.Boolean]]), + }, + reduce: reduceSetDefaultLoginInstance_v1, + want: wantReduce{ + aggregateType: feature_v2.AggregateType, + sequence: 15, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "INSERT INTO projections.instance_features (instance_id, key, creation_date, change_date, sequence, value) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (instance_id, key) DO UPDATE SET (creation_date, change_date, sequence, value) = (projections.instance_features.creation_date, EXCLUDED.change_date, EXCLUDED.sequence, EXCLUDED.value)", + expectedArgs: []interface{}{ + "agg-id", + "login_default_org", + anyArg{}, + anyArg{}, + uint64(15), + []byte("true"), + }, + }, + }, + }, + }, + }, + { + name: "reduceInstanceResetFeatures", + args: args{ + event: getEvent( + testEvent( + feature_v2.InstanceResetEventType, + feature_v2.AggregateType, + []byte{}, + ), eventstore.GenericEventMapper[feature_v2.ResetEvent]), + }, + reduce: reduceInstanceResetFeatures, + want: wantReduce{ + aggregateType: feature_v2.AggregateType, + sequence: 15, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "DELETE FROM projections.instance_features WHERE (instance_id = $1)", + expectedArgs: []interface{}{ + "agg-id", + }, + }, + }, + }, + }, + }, + { + name: "instance reduceInstanceRemoved", + args: args{ + event: getEvent( + testEvent( + instance.InstanceRemovedEventType, + instance.AggregateType, + nil, + ), instance.InstanceRemovedEventMapper), + }, + reduce: reduceInstanceRemovedHelper(InstanceDomainInstanceIDCol), + want: wantReduce{ + aggregateType: eventstore.AggregateType("instance"), + sequence: 15, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "DELETE FROM projections.instance_features WHERE (instance_id = $1)", + expectedArgs: []interface{}{ + "agg-id", + }, + }, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + event := baseEvent(t) + got, err := tt.reduce(event) + if ok := zerrors.IsErrorInvalidArgument(err); !ok { + t.Errorf("no wrong event mapping: %v, got: %v", err, got) + } + + event = tt.args.event(t) + got, err = tt.reduce(event) + assertReduce(t, got, err, InstanceFeatureTable, tt.want) + }) + } +} diff --git a/internal/query/projection/projection.go b/internal/query/projection/projection.go index 7c67b5cc3..95317a781 100644 --- a/internal/query/projection/projection.go +++ b/internal/query/projection/projection.go @@ -72,6 +72,8 @@ var ( QuotaProjection *quotaProjection LimitsProjection *handler.Handler RestrictionsProjection *handler.Handler + SystemFeatureProjection *handler.Handler + InstanceFeatureProjection *handler.Handler ) type projection interface { @@ -148,6 +150,8 @@ func Create(ctx context.Context, sqlClient *database.DB, es handler.EventStore, QuotaProjection = newQuotaProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["quotas"])) LimitsProjection = newLimitsProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["limits"])) RestrictionsProjection = newRestrictionsProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["restrictions"])) + SystemFeatureProjection = newSystemFeatureProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["system_features"])) + InstanceFeatureProjection = newInstanceFeatureProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["instance_features"])) newProjectionsList() return nil } @@ -257,5 +261,7 @@ func newProjectionsList() { QuotaProjection.handler, LimitsProjection, RestrictionsProjection, + SystemFeatureProjection, + InstanceFeatureProjection, } } diff --git a/internal/query/projection/system_features.go b/internal/query/projection/system_features.go new file mode 100644 index 000000000..3d3f6bd1c --- /dev/null +++ b/internal/query/projection/system_features.go @@ -0,0 +1,98 @@ +package projection + +import ( + "context" + + "github.com/zitadel/zitadel/internal/eventstore" + old_handler "github.com/zitadel/zitadel/internal/eventstore/handler" + "github.com/zitadel/zitadel/internal/eventstore/handler/v2" + "github.com/zitadel/zitadel/internal/repository/feature/feature_v2" + "github.com/zitadel/zitadel/internal/zerrors" +) + +const ( + SystemFeatureTable = "projections.system_features" + + SystemFeatureKeyCol = "key" + SystemFeatureCreationDateCol = "creation_date" + SystemFeatureChangeDateCol = "change_date" + SystemFeatureSequenceCol = "sequence" + SystemFeatureValueCol = "value" +) + +type systemFeatureProjection struct{} + +func newSystemFeatureProjection(ctx context.Context, config handler.Config) *handler.Handler { + return handler.NewHandler(ctx, &config, new(systemFeatureProjection)) +} + +func (*systemFeatureProjection) Name() string { + return SystemFeatureTable +} + +func (*systemFeatureProjection) Init() *old_handler.Check { + return handler.NewTableCheck(handler.NewTable( + []*handler.InitColumn{ + handler.NewColumn(SystemFeatureKeyCol, handler.ColumnTypeText), + handler.NewColumn(SystemFeatureCreationDateCol, handler.ColumnTypeTimestamp), + handler.NewColumn(SystemFeatureChangeDateCol, handler.ColumnTypeTimestamp), + handler.NewColumn(SystemFeatureSequenceCol, handler.ColumnTypeInt64), + handler.NewColumn(SystemFeatureValueCol, handler.ColumnTypeJSONB), + }, + handler.NewPrimaryKey(SystemFeatureKeyCol), + )) +} + +func (*systemFeatureProjection) Reducers() []handler.AggregateReducer { + return []handler.AggregateReducer{{ + Aggregate: feature_v2.AggregateType, + EventReducers: []handler.EventReducer{ + { + Event: feature_v2.SystemResetEventType, + Reduce: reduceSystemResetFeatures, + }, + { + Event: feature_v2.SystemLoginDefaultOrgEventType, + Reduce: reduceSystemSetFeature[bool], + }, + { + Event: feature_v2.SystemTriggerIntrospectionProjectionsEventType, + Reduce: reduceSystemSetFeature[bool], + }, + { + Event: feature_v2.SystemLegacyIntrospectionEventType, + Reduce: reduceSystemSetFeature[bool], + }, + }, + }} +} + +func reduceSystemSetFeature[T any](event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*feature_v2.SetEvent[T]) + if !ok { + return nil, zerrors.ThrowInvalidArgumentf(nil, "PROJE-uPh8O", "reduce.wrong.event.type %T", event) + } + f, err := e.FeatureJSON() + if err != nil { + return nil, err + } + columns := []handler.Column{ + handler.NewCol(SystemFeatureKeyCol, f.Key.String()), + handler.NewCol(SystemFeatureCreationDateCol, handler.OnlySetValueOnInsert(SystemFeatureTable, e.CreationDate())), + handler.NewCol(SystemFeatureChangeDateCol, e.CreationDate()), + handler.NewCol(SystemFeatureSequenceCol, e.Sequence()), + handler.NewCol(SystemFeatureValueCol, f.Value), + } + return handler.NewUpsertStatement(e, columns[0:1], columns), nil +} + +func reduceSystemResetFeatures(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*feature_v2.ResetEvent) + if !ok { + return nil, zerrors.ThrowInvalidArgumentf(nil, "PROJE-roo6A", "reduce.wrong.event.type %T", event) + } + return handler.NewDeleteStatement(e, []handler.Condition{ + // Hack: need at least one condition or the query builder will throw us an error + handler.NewIsNotNullCond(SystemFeatureKeyCol), + }), nil +} diff --git a/internal/query/projection/system_features_test.go b/internal/query/projection/system_features_test.go new file mode 100644 index 000000000..9bc19573c --- /dev/null +++ b/internal/query/projection/system_features_test.go @@ -0,0 +1,90 @@ +package projection + +import ( + "testing" + + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/eventstore/handler/v2" + "github.com/zitadel/zitadel/internal/repository/feature/feature_v2" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func TestSystemFeaturesProjection_reduces(t *testing.T) { + type args struct { + event func(t *testing.T) eventstore.Event + } + tests := []struct { + name string + args args + reduce func(event eventstore.Event) (*handler.Statement, error) + want wantReduce + }{ + { + name: "reduceSystemSetFeature", + args: args{ + event: getEvent( + testEvent( + feature_v2.SystemLegacyIntrospectionEventType, + feature_v2.AggregateType, + []byte(`{"value": true}`), + ), eventstore.GenericEventMapper[feature_v2.SetEvent[bool]]), + }, + reduce: reduceSystemSetFeature[bool], + want: wantReduce{ + aggregateType: feature_v2.AggregateType, + sequence: 15, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "INSERT INTO projections.system_features (key, creation_date, change_date, sequence, value) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (key) DO UPDATE SET (creation_date, change_date, sequence, value) = (projections.system_features.creation_date, EXCLUDED.change_date, EXCLUDED.sequence, EXCLUDED.value)", + expectedArgs: []interface{}{ + "legacy_introspection", + anyArg{}, + anyArg{}, + uint64(15), + []byte("true"), + }, + }, + }, + }, + }, + }, + { + name: "reduceSystemResetFeatures", + args: args{ + event: getEvent( + testEvent( + feature_v2.SystemResetEventType, + feature_v2.AggregateType, + []byte{}, + ), eventstore.GenericEventMapper[feature_v2.ResetEvent]), + }, + reduce: reduceSystemResetFeatures, + want: wantReduce{ + aggregateType: feature_v2.AggregateType, + sequence: 15, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "DELETE FROM projections.system_features WHERE (key IS NOT NULL)", + expectedArgs: []interface{}{}, + }, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + event := baseEvent(t) + got, err := tt.reduce(event) + if ok := zerrors.IsErrorInvalidArgument(err); !ok { + t.Errorf("no wrong event mapping: %v, got: %v", err, got) + } + + event = tt.args.event(t) + got, err = tt.reduce(event) + assertReduce(t, got, err, SystemFeatureTable, tt.want) + }) + } +} diff --git a/internal/query/system_features.go b/internal/query/system_features.go new file mode 100644 index 000000000..8ed1cfca5 --- /dev/null +++ b/internal/query/system_features.go @@ -0,0 +1,30 @@ +package query + +import ( + "context" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/feature" +) + +type FeatureSource[T any] struct { + Level feature.Level + Value T +} + +type SystemFeatures struct { + Details *domain.ObjectDetails + + LoginDefaultOrg FeatureSource[bool] + TriggerIntrospectionProjections FeatureSource[bool] + LegacyIntrospection FeatureSource[bool] +} + +func (q *Queries) GetSystemFeatures(ctx context.Context) (_ *SystemFeatures, err error) { + m := NewSystemFeaturesReadModel() + if err := q.eventstore.FilterToQueryReducer(ctx, m); err != nil { + return nil, err + } + m.system.Details = readModelToObjectDetails(m.ReadModel) + return m.system, nil +} diff --git a/internal/query/system_features_model.go b/internal/query/system_features_model.go new file mode 100644 index 000000000..87fa8c93e --- /dev/null +++ b/internal/query/system_features_model.go @@ -0,0 +1,82 @@ +package query + +import ( + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/feature" + "github.com/zitadel/zitadel/internal/repository/feature/feature_v2" +) + +type SystemFeaturesReadModel struct { + *eventstore.ReadModel + system *SystemFeatures +} + +func NewSystemFeaturesReadModel() *SystemFeaturesReadModel { + m := &SystemFeaturesReadModel{ + ReadModel: &eventstore.ReadModel{ + AggregateID: "SYSTEM", + ResourceOwner: "SYSTEM", + }, + system: new(SystemFeatures), + } + return m +} + +func (m *SystemFeaturesReadModel) Reduce() error { + for _, event := range m.Events { + switch e := event.(type) { + case *feature_v2.ResetEvent: + m.reduceReset() + case *feature_v2.SetEvent[bool]: + err := m.reduceBoolFeature(e) + if err != nil { + return err + } + } + } + return m.ReadModel.Reduce() +} + +func (m *SystemFeaturesReadModel) Query() *eventstore.SearchQueryBuilder { + return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + AwaitOpenTransactions(). + AddQuery(). + AggregateTypes(feature_v2.AggregateType). + AggregateIDs(m.AggregateID). + EventTypes( + feature_v2.SystemResetEventType, + feature_v2.SystemLoginDefaultOrgEventType, + feature_v2.SystemTriggerIntrospectionProjectionsEventType, + feature_v2.SystemLegacyIntrospectionEventType, + ). + Builder().ResourceOwner(m.ResourceOwner) +} + +func (m *SystemFeaturesReadModel) reduceReset() { + m.system = new(SystemFeatures) +} + +func (m *SystemFeaturesReadModel) reduceBoolFeature(event *feature_v2.SetEvent[bool]) error { + level, key, err := event.FeatureInfo() + if err != nil { + return err + } + var dst *FeatureSource[bool] + + switch key { + case feature.KeyUnspecified: + return nil + case feature.KeyLoginDefaultOrg: + dst = &m.system.LoginDefaultOrg + case feature.KeyTriggerIntrospectionProjections: + dst = &m.system.TriggerIntrospectionProjections + case feature.KeyLegacyIntrospection: + dst = &m.system.LegacyIntrospection + } + + *dst = FeatureSource[bool]{ + Level: level, + Value: event.Value, + } + return nil +} diff --git a/internal/query/system_features_test.go b/internal/query/system_features_test.go new file mode 100644 index 000000000..051e0b689 --- /dev/null +++ b/internal/query/system_features_test.go @@ -0,0 +1,179 @@ +package query + +import ( + "context" + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/feature" + "github.com/zitadel/zitadel/internal/repository/feature/feature_v2" +) + +func TestQueries_GetSystemFeatures(t *testing.T) { + aggregate := feature_v2.NewAggregate("SYSTEM", "SYSTEM") + + tests := []struct { + name string + eventstore func(*testing.T) *eventstore.Eventstore + want *SystemFeatures + wantErr error + }{ + { + name: "filter error", + eventstore: expectEventstore( + expectFilterError(io.ErrClosedPipe), + ), + wantErr: io.ErrClosedPipe, + }, + { + name: "no features set", + eventstore: expectEventstore( + expectFilter(), + ), + want: &SystemFeatures{ + Details: &domain.ObjectDetails{ + ResourceOwner: "SYSTEM", + }, + }, + }, + { + name: "all features set", + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher(feature_v2.NewSetEvent[bool]( + context.Background(), aggregate, + feature_v2.SystemLoginDefaultOrgEventType, false, + )), + eventFromEventPusher(feature_v2.NewSetEvent[bool]( + context.Background(), aggregate, + feature_v2.SystemTriggerIntrospectionProjectionsEventType, true, + )), + eventFromEventPusher(feature_v2.NewSetEvent[bool]( + context.Background(), aggregate, + feature_v2.SystemLegacyIntrospectionEventType, false, + )), + ), + ), + want: &SystemFeatures{ + Details: &domain.ObjectDetails{ + ResourceOwner: "SYSTEM", + }, + LoginDefaultOrg: FeatureSource[bool]{ + Level: feature.LevelSystem, + Value: false, + }, + TriggerIntrospectionProjections: FeatureSource[bool]{ + Level: feature.LevelSystem, + Value: true, + }, + LegacyIntrospection: FeatureSource[bool]{ + Level: feature.LevelSystem, + Value: false, + }, + }, + }, + { + name: "all features set, reset, set some feature", + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher(feature_v2.NewSetEvent[bool]( + context.Background(), aggregate, + feature_v2.SystemLoginDefaultOrgEventType, false, + )), + eventFromEventPusher(feature_v2.NewSetEvent[bool]( + context.Background(), aggregate, + feature_v2.SystemTriggerIntrospectionProjectionsEventType, true, + )), + eventFromEventPusher(feature_v2.NewSetEvent[bool]( + context.Background(), aggregate, + feature_v2.SystemLegacyIntrospectionEventType, false, + )), + eventFromEventPusher(feature_v2.NewResetEvent( + context.Background(), aggregate, + feature_v2.SystemResetEventType, + )), + eventFromEventPusher(feature_v2.NewSetEvent[bool]( + context.Background(), aggregate, + feature_v2.SystemTriggerIntrospectionProjectionsEventType, true, + )), + ), + ), + want: &SystemFeatures{ + Details: &domain.ObjectDetails{ + ResourceOwner: "SYSTEM", + }, + LoginDefaultOrg: FeatureSource[bool]{ + Level: feature.LevelUnspecified, + Value: false, + }, + TriggerIntrospectionProjections: FeatureSource[bool]{ + Level: feature.LevelSystem, + Value: true, + }, + LegacyIntrospection: FeatureSource[bool]{ + Level: feature.LevelUnspecified, + Value: false, + }, + }, + }, + { + name: "all features set, reset, set some feature, not cascaded", + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher(feature_v2.NewSetEvent[bool]( + context.Background(), aggregate, + feature_v2.SystemLoginDefaultOrgEventType, false, + )), + eventFromEventPusher(feature_v2.NewSetEvent[bool]( + context.Background(), aggregate, + feature_v2.SystemTriggerIntrospectionProjectionsEventType, true, + )), + eventFromEventPusher(feature_v2.NewSetEvent[bool]( + context.Background(), aggregate, + feature_v2.SystemLegacyIntrospectionEventType, false, + )), + eventFromEventPusher(feature_v2.NewResetEvent( + context.Background(), aggregate, + feature_v2.SystemResetEventType, + )), + eventFromEventPusher(feature_v2.NewSetEvent[bool]( + context.Background(), aggregate, + feature_v2.SystemTriggerIntrospectionProjectionsEventType, true, + )), + ), + ), + want: &SystemFeatures{ + Details: &domain.ObjectDetails{ + ResourceOwner: "SYSTEM", + }, + LoginDefaultOrg: FeatureSource[bool]{ + Level: feature.LevelUnspecified, + Value: false, + }, + TriggerIntrospectionProjections: FeatureSource[bool]{ + Level: feature.LevelSystem, + Value: true, + }, + LegacyIntrospection: FeatureSource[bool]{ + Level: feature.LevelUnspecified, + Value: false, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + q := &Queries{ + eventstore: tt.eventstore(t), + } + got, err := q.GetSystemFeatures(context.Background()) + require.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/repository/feature/aggregate.go b/internal/repository/feature/aggregate.go index 479df776d..a2452d951 100644 --- a/internal/repository/feature/aggregate.go +++ b/internal/repository/feature/aggregate.go @@ -13,18 +13,3 @@ const ( AggregateType = "feature" AggregateVersion = "v1" ) - -type Aggregate struct { - eventstore.Aggregate -} - -func NewAggregate(id, resourceOwner string) *Aggregate { - return &Aggregate{ - Aggregate: eventstore.Aggregate{ - Type: AggregateType, - Version: AggregateVersion, - ID: id, - ResourceOwner: resourceOwner, - }, - } -} diff --git a/internal/repository/feature/feature.go b/internal/repository/feature/feature.go index f0d4a1ae5..25ed66050 100644 --- a/internal/repository/feature/feature.go +++ b/internal/repository/feature/feature.go @@ -1,3 +1,5 @@ +// Package feature implements the v1 feature repository. +// DEPRECATED: use ./feature_v2 instead. package feature import ( @@ -6,14 +8,22 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/feature/feature_v2" ) var ( - DefaultLoginInstanceEventType = EventTypeFromFeature(domain.FeatureLoginDefaultOrg) + DefaultLoginInstanceEventType = eventTypePrefix + eventstore.EventType(strings.ToLower("FeatureLoginDefaultOrg")) + setSuffix ) -func EventTypeFromFeature(feature domain.Feature) eventstore.EventType { - return eventTypePrefix + eventstore.EventType(strings.ToLower(feature.String())) + setSuffix +// DefaultLoginInstanceEventToV2 upgrades the SetEvent to a V2 SetEvent so that +// the v2 reducers can handle the V1 events. +func DefaultLoginInstanceEventToV2(e *SetEvent[Boolean]) *feature_v2.SetEvent[bool] { + v2e := &feature_v2.SetEvent[bool]{ + BaseEvent: e.BaseEvent, + Value: e.Value.Boolean, + } + v2e.BaseEvent.EventType = feature_v2.InstanceLoginDefaultOrgEventType + return v2e } type SetEvent[T SetEventType] struct { diff --git a/internal/repository/feature/feature_v2/aggregate.go b/internal/repository/feature/feature_v2/aggregate.go new file mode 100644 index 000000000..581636730 --- /dev/null +++ b/internal/repository/feature/feature_v2/aggregate.go @@ -0,0 +1,25 @@ +package feature_v2 + +import ( + "github.com/zitadel/zitadel/internal/eventstore" +) + +const ( + AggregateType = "feature" + AggregateVersion = "v2" +) + +type Aggregate struct { + eventstore.Aggregate +} + +func NewAggregate(id, resourceOwner string) *Aggregate { + return &Aggregate{ + Aggregate: eventstore.Aggregate{ + Type: AggregateType, + Version: AggregateVersion, + ID: id, + ResourceOwner: resourceOwner, + }, + } +} diff --git a/internal/repository/feature/feature_v2/eventstore.go b/internal/repository/feature/feature_v2/eventstore.go new file mode 100644 index 000000000..4b0c1d293 --- /dev/null +++ b/internal/repository/feature/feature_v2/eventstore.go @@ -0,0 +1,16 @@ +package feature_v2 + +import ( + "github.com/zitadel/zitadel/internal/eventstore" +) + +func init() { + eventstore.RegisterFilterEventMapper(AggregateType, SystemResetEventType, eventstore.GenericEventMapper[ResetEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, SystemLoginDefaultOrgEventType, eventstore.GenericEventMapper[SetEvent[bool]]) + eventstore.RegisterFilterEventMapper(AggregateType, SystemTriggerIntrospectionProjectionsEventType, eventstore.GenericEventMapper[SetEvent[bool]]) + eventstore.RegisterFilterEventMapper(AggregateType, SystemLegacyIntrospectionEventType, eventstore.GenericEventMapper[SetEvent[bool]]) + eventstore.RegisterFilterEventMapper(AggregateType, InstanceResetEventType, eventstore.GenericEventMapper[ResetEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, InstanceLoginDefaultOrgEventType, eventstore.GenericEventMapper[SetEvent[bool]]) + eventstore.RegisterFilterEventMapper(AggregateType, InstanceTriggerIntrospectionProjectionsEventType, eventstore.GenericEventMapper[SetEvent[bool]]) + eventstore.RegisterFilterEventMapper(AggregateType, InstanceLegacyIntrospectionEventType, eventstore.GenericEventMapper[SetEvent[bool]]) +} diff --git a/internal/repository/feature/feature_v2/feature.go b/internal/repository/feature/feature_v2/feature.go new file mode 100644 index 000000000..7419ecf5b --- /dev/null +++ b/internal/repository/feature/feature_v2/feature.go @@ -0,0 +1,132 @@ +package feature_v2 + +import ( + "context" + "encoding/json" + "strings" + + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/feature" + "github.com/zitadel/zitadel/internal/zerrors" +) + +var ( + SystemResetEventType = resetEventTypeFromFeature(feature.LevelSystem) + SystemLoginDefaultOrgEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyLoginDefaultOrg) + SystemTriggerIntrospectionProjectionsEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyTriggerIntrospectionProjections) + SystemLegacyIntrospectionEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyLegacyIntrospection) + + InstanceResetEventType = resetEventTypeFromFeature(feature.LevelInstance) + InstanceLoginDefaultOrgEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyLoginDefaultOrg) + InstanceTriggerIntrospectionProjectionsEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyTriggerIntrospectionProjections) + InstanceLegacyIntrospectionEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyLegacyIntrospection) +) + +const ( + resetSuffix = "reset" + setSuffix = "set" +) + +func resetEventTypeFromFeature(level feature.Level) eventstore.EventType { + return eventstore.EventType(strings.Join([]string{AggregateType, level.String(), resetSuffix}, ".")) +} + +func setEventTypeFromFeature(level feature.Level, key feature.Key) eventstore.EventType { + return eventstore.EventType(strings.Join([]string{AggregateType, level.String(), key.String(), setSuffix}, ".")) +} + +type ResetEvent struct { + *eventstore.BaseEvent `json:"-"` +} + +func (e *ResetEvent) SetBaseEvent(b *eventstore.BaseEvent) { + e.BaseEvent = b +} + +func (e *ResetEvent) Payload() interface{} { + return e +} + +func (e *ResetEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func NewResetEvent( + ctx context.Context, + aggregate *Aggregate, + eventType eventstore.EventType, +) *ResetEvent { + return &ResetEvent{ + eventstore.NewBaseEventForPush( + ctx, &aggregate.Aggregate, eventType), + } +} + +type SetEvent[T any] struct { + *eventstore.BaseEvent `json:"-"` + + Value T +} + +func (e *SetEvent[T]) SetBaseEvent(b *eventstore.BaseEvent) { + e.BaseEvent = b +} + +func (e *SetEvent[T]) Payload() interface{} { + return e +} + +func (e *SetEvent[T]) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +type FeatureJSON struct { + Key feature.Key + Value []byte +} + +// FeatureJSON prepares converts the event to a key-value pair with a JSON value payload. +func (e *SetEvent[T]) FeatureJSON() (*FeatureJSON, error) { + _, key, err := e.FeatureInfo() + if err != nil { + return nil, err + } + jsonValue, err := json.Marshal(e.Value) + if err != nil { + return nil, zerrors.ThrowInternalf(err, "FEAT-go9Ji", "reduce.wrong.event.type %s", e.EventType) + } + return &FeatureJSON{ + Key: key, + Value: jsonValue, + }, nil +} + +// FeatureInfo extracts a feature's level and key from the event. +func (e *SetEvent[T]) FeatureInfo() (feature.Level, feature.Key, error) { + ss := strings.Split(string(e.EventType), ".") + if len(ss) != 4 { + return 0, 0, zerrors.ThrowInternalf(nil, "FEAT-Ahs4m", "reduce.wrong.event.type %s", e.EventType) + } + level, err := feature.LevelString(ss[1]) + if err != nil { + return 0, 0, zerrors.ThrowInternalf(err, "FEAT-Boo2i", "reduce.wrong.event.type %s", e.EventType) + } + key, err := feature.KeyString(ss[2]) + if err != nil { + return 0, 0, zerrors.ThrowInternalf(err, "FEAT-eir0M", "reduce.wrong.event.type %s", e.EventType) + } + return level, key, nil +} + +func NewSetEvent[T any]( + ctx context.Context, + aggregate *Aggregate, + eventType eventstore.EventType, + value T, +) *SetEvent[T] { + return &SetEvent[T]{ + eventstore.NewBaseEventForPush( + ctx, &aggregate.Aggregate, eventType), + value, + } +} diff --git a/internal/repository/feature/feature_v2/feature_test.go b/internal/repository/feature/feature_v2/feature_test.go new file mode 100644 index 000000000..e1bff0525 --- /dev/null +++ b/internal/repository/feature/feature_v2/feature_test.go @@ -0,0 +1,118 @@ +package feature_v2 + +import ( + "math" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/feature" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func TestSetEvent_FeatureJSON(t *testing.T) { + tests := []struct { + name string + e *SetEvent[float64] // using float so it's easy to create marshal errors + want *FeatureJSON + wantErr error + }{ + { + name: "invalid key error", + e: &SetEvent[float64]{ + BaseEvent: &eventstore.BaseEvent{ + EventType: "feature.system.foo_bar.some_feat", + }, + }, + wantErr: zerrors.ThrowInternalf(nil, "FEAT-eir0M", "reduce.wrong.event.type %s", "feature.system.foo_bar.some_feat"), + }, + { + name: "marshal error", + e: &SetEvent[float64]{ + BaseEvent: &eventstore.BaseEvent{ + EventType: SystemLoginDefaultOrgEventType, + }, + Value: math.NaN(), + }, + wantErr: zerrors.ThrowInternalf(nil, "FEAT-go9Ji", "reduce.wrong.event.type %s", SystemLoginDefaultOrgEventType), + }, + { + name: "success", + e: &SetEvent[float64]{ + BaseEvent: &eventstore.BaseEvent{ + EventType: SystemLoginDefaultOrgEventType, + }, + Value: 555, + }, + want: &FeatureJSON{ + Key: feature.KeyLoginDefaultOrg, + Value: []byte(`555`), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.e.FeatureJSON() + require.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestSetEvent_FeatureInfo(t *testing.T) { + tests := []struct { + name string + e *SetEvent[bool] + want feature.Level + want1 feature.Key + wantErr error + }{ + { + name: "format error", + e: &SetEvent[bool]{ + BaseEvent: &eventstore.BaseEvent{ + EventType: "foo.bar", + }, + }, + wantErr: zerrors.ThrowInternalf(nil, "FEAT-Ahs4m", "reduce.wrong.event.type %s", "foo.bar"), + }, + { + name: "level error", + e: &SetEvent[bool]{ + BaseEvent: &eventstore.BaseEvent{ + EventType: "feature.foo.bar.something", + }, + }, + wantErr: zerrors.ThrowInternalf(nil, "FEAT-Boo2i", "reduce.wrong.event.type %s", "feature.foo.bar.something"), + }, + { + name: "key error", + e: &SetEvent[bool]{ + BaseEvent: &eventstore.BaseEvent{ + EventType: "feature.system.bar.something", + }, + }, + wantErr: zerrors.ThrowInternalf(nil, "FEAT-eir0M", "reduce.wrong.event.type %s", "feature.system.bar.something"), + }, + { + name: "success", + e: &SetEvent[bool]{ + BaseEvent: &eventstore.BaseEvent{ + EventType: SystemLoginDefaultOrgEventType, + }, + }, + want: feature.LevelSystem, + want1: feature.KeyLoginDefaultOrg, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, got1, err := tt.e.FeatureInfo() + require.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, tt.want, got) + assert.Equal(t, tt.want1, got1) + }) + } +} diff --git a/proto/zitadel/feature/v2beta/feature.proto b/proto/zitadel/feature/v2beta/feature.proto new file mode 100644 index 000000000..484bb667f --- /dev/null +++ b/proto/zitadel/feature/v2beta/feature.proto @@ -0,0 +1,35 @@ +syntax = "proto3"; + +package zitadel.feature.v2beta; + +import "protoc-gen-openapiv2/options/annotations.proto"; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta;feature"; + + +enum Source { + SOURCE_UNSPECIFIED = 0; + reserved 1; // in case we want to implement a "DEFAULT" level + SOURCE_SYSTEM = 2; + SOURCE_INSTANCE = 3; + SOURCE_ORGANIZATION = 4; + SOURCE_PROJECT = 5; // reserved for future use + SOURCE_APP = 6; // reserved for future use + SOURCE_USER = 7; +} + +// FeatureFlag is a simple boolean Feature setting, without further payload. +message FeatureFlag { + bool enabled = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "false"; + description: "Whether a feature is enabled."; + } + ]; + + Source source = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "The source where the setting of the feature was defined. The source may be the resource itself or a resource owner through inheritance."; + } + ]; +} diff --git a/proto/zitadel/feature/v2beta/feature_service.proto b/proto/zitadel/feature/v2beta/feature_service.proto new file mode 100644 index 000000000..3c610e1c1 --- /dev/null +++ b/proto/zitadel/feature/v2beta/feature_service.proto @@ -0,0 +1,395 @@ +syntax = "proto3"; + +package zitadel.feature.v2beta; + +import "google/api/annotations.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; + +import "zitadel/feature/v2beta/system.proto"; +import "zitadel/feature/v2beta/instance.proto"; +import "zitadel/feature/v2beta/organization.proto"; +import "zitadel/feature/v2beta/user.proto"; +import "zitadel/protoc_gen_zitadel/v2/options.proto"; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta;feature"; + +option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = { + info: { + title: "Feature Service"; + version: "2.0-beta"; + description: "This API is intended to manage features for ZITADEL. Feature settings that are available on multiple \"levels\", such as instance and organization. The higher level instance acts as a default for the lower level. When a feature is set on multiple levels, the lower level takes precedence. Features can be experimental where ZITADEL will assume a sane default, such as disabled. When over time confidence in such a feature grows, ZITADEL can default to enabling the feature. As a final step we might choose to always enable a feature and remove the setting from this API, reserving the proto field number. Such removal is not considered a breaking change. Setting a removed field will effectively result in a no-op. This project is in beta state. It can AND will continue breaking until a stable version is released."; + contact:{ + name: "ZITADEL" + url: "https://zitadel.com" + email: "hi@zitadel.com" + } + license: { + name: "Apache 2.0", + url: "https://github.com/zitadel/zitadel/blob/main/LICENSE"; + }; + }; + schemes: HTTPS; + schemes: HTTP; + + consumes: "application/json"; + consumes: "application/grpc"; + + produces: "application/json"; + produces: "application/grpc"; + + consumes: "application/grpc-web+proto"; + produces: "application/grpc-web+proto"; + + host: "$CUSTOM-DOMAIN"; + base_path: "/"; + + external_docs: { + description: "Detailed information about ZITADEL", + url: "https://zitadel.com/docs" + } + security_definitions: { + security: { + key: "OAuth2"; + value: { + type: TYPE_OAUTH2; + flow: FLOW_ACCESS_CODE; + authorization_url: "$CUSTOM-DOMAIN/oauth/v2/authorize"; + token_url: "$CUSTOM-DOMAIN/oauth/v2/token"; + scopes: { + scope: { + key: "openid"; + value: "openid"; + } + scope: { + key: "urn:zitadel:iam:org:project:id:zitadel:aud"; + value: "urn:zitadel:iam:org:project:id:zitadel:aud"; + } + } + } + } + } + security: { + security_requirement: { + key: "OAuth2"; + value: { + scope: "openid"; + scope: "urn:zitadel:iam:org:project:id:zitadel:aud"; + } + } + } + responses: { + key: "403"; + value: { + description: "Returned when the user does not have permission to access the resource."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + } + } + } + } + responses: { + key: "404"; + value: { + description: "Returned when the resource has no feature flag settings and inheritance from the parent is disabled."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + } + } + } + } +}; + +// FeatureService is intended to manage features for ZITADEL. +// +// Feature settings that are available on multiple "levels", such as instance and organization. +// The higher level (instance) acts as a default for the lower level (organization). +// When a feature is set on multiple levels, the lower level takes precedence. +// +// Features can be experimental where ZITADEL will assume a sane default, such as disabled. +// When over time confidence in such a feature grows, ZITADEL can default to enabling the feature. +// As a final step we might choose to always enable a feature and remove the setting from this API, +// reserving the proto field number. Such removal is not considered a breaking change. +// Setting a removed field will effectively result in a no-op. +service FeatureService { + rpc SetSystemFeatures (SetSystemFeaturesRequest) returns (SetSystemFeaturesResponse) { + option (google.api.http) = { + put: "/v2beta/features/system" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "system.feature.write" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Set system level features"; + description: "Configure and set features that apply to the complete system. Only fields present in the request are set or unset." + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + + rpc ResetSystemFeatures (ResetSystemFeaturesRequest) returns (ResetSystemFeaturesResponse) { + option (google.api.http) = { + delete: "/v2beta/features/system" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "system.feature.delete" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Reset system level features"; + description: "Deletes ALL configured features for the system, reverting the behaviors to system defaults." + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + + rpc GetSystemFeatures (GetSystemFeaturesRequest) returns (GetSystemFeaturesResponse) { + option (google.api.http) = { + get: "/v2beta/features/system" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "system.feature.read" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Get system level features"; + description: "Returns all configured features for the system. Unset fields mean the feature is the current system default." + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + + rpc SetInstanceFeatures (SetInstanceFeaturesRequest) returns (SetInstanceFeaturesResponse) { + option (google.api.http) = { + put: "/v2beta/features/instance" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "iam.feature.write" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Set instance level features"; + description: "Configure and set features that apply to a complete instance. Only fields present in the request are set or unset." + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + + rpc ResetInstanceFeatures (ResetInstanceFeaturesRequest) returns (ResetInstanceFeaturesResponse) { + option (google.api.http) = { + delete: "/v2beta/features/instance" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "iam.feature.delete" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Reset instance level features"; + description: "Deletes ALL configured features for an instance, reverting the behaviors to system defaults." + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + + rpc GetInstanceFeatures (GetInstanceFeaturesRequest) returns (GetInstanceFeaturesResponse) { + option (google.api.http) = { + get: "/v2beta/features/instance" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "iam.feature.read" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Get instance level features"; + description: "Returns all configured features for an instance. Unset fields mean the feature is the current system default." + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + + rpc SetOrganizationFeatures (SetOrganizationFeaturesRequest) returns (SetOrganizationFeaturesResponse) { + option (google.api.http) = { + put: "/v2beta/features/organization/{organization_id}" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "org.feature.write" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Set organization level features"; + description: "Configure and set features that apply to a complete instance. Only fields present in the request are set or unset." + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + + rpc ResetOrganizationFeatures (ResetOrganizationFeaturesRequest) returns (ResetOrganizationFeaturesResponse) { + option (google.api.http) = { + delete: "/v2beta/features/organization/{organization_id}" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "org.feature.write" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Reset organization level features"; + description: "Deletes ALL configured features for an organization, reverting the behaviors to instance defaults." + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + + rpc GetOrganizationFeatures(GetOrganizationFeaturesRequest) returns (GetOrganizationFeaturesResponse) { + option (google.api.http) = { + get: "/v2beta/features/organization/{organization_id}" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "org.feature.read" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Get organization level features"; + description: "Returns all configured features for an organization. Unset fields mean the feature is the current instance default." + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + + rpc SetUserFeatures(SetUserFeatureRequest) returns (SetUserFeaturesResponse) { + option (google.api.http) = { + put: "/v2beta/features/user/{user_id}" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "user.feature.write" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Set user level features"; + description: "Configure and set features that apply to an user. Only fields present in the request are set or unset." + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + + rpc ResetUserFeatures(ResetUserFeaturesRequest) returns (ResetUserFeaturesResponse) { + option (google.api.http) = { + delete: "/v2beta/features/user/{user_id}" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "user.feature.write" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Reset user level features"; + description: "Deletes ALL configured features for a user, reverting the behaviors to organization defaults." + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + + rpc GetUserFeatures(GetUserFeaturesRequest) returns (GetUserFeaturesResponse) { + option (google.api.http) = { + get: "/v2beta/features/user/{user_id}" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "user.feature.read" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Get organization level features"; + description: "Returns all configured features for an organization. Unset fields mean the feature is the current instance default." + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } +} diff --git a/proto/zitadel/feature/v2beta/instance.proto b/proto/zitadel/feature/v2beta/instance.proto new file mode 100644 index 000000000..211bb6cc2 --- /dev/null +++ b/proto/zitadel/feature/v2beta/instance.proto @@ -0,0 +1,76 @@ +syntax = "proto3"; + +package zitadel.feature.v2beta; + +import "protoc-gen-openapiv2/options/annotations.proto"; +import "validate/validate.proto"; + +import "zitadel/object/v2beta/object.proto"; +import "zitadel/feature/v2beta/feature.proto"; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta;feature"; + +message SetInstanceFeaturesRequest{ + optional bool login_default_org = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "true"; + description: "The login UI will use the settings of the default org (and not from the instance) if no organization context is set"; + } + ]; + optional bool oidc_trigger_introspection_projections = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "true"; + description: "Enable projection triggers during an introspection request. This can act as workaround if there are noticeable consistency issues in the introspection response but can have an impact on performance. We are planning to remove triggers for introspection requests in the future. Please raise an issue if you needed to enable this feature."; + } + ]; + + optional bool oidc_legacy_introspection = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "true"; + description: "We have recently refactored the introspection endpoint for performance reasons. This feature can be used to rollback to the legacy implementation if unexpected bugs arise. Please raise an issue if you needed to enable this feature."; + } + ]; +} + +message SetInstanceFeaturesResponse { + zitadel.object.v2beta.Details details = 1; +} + +message ResetInstanceFeaturesRequest {} + +message ResetInstanceFeaturesResponse { + zitadel.object.v2beta.Details details = 1; +} + +message GetInstanceFeaturesRequest { + bool inheritance = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "true"; + description: "Inherit unset features from the resource owners. This option is recursive: if the flag is set, the resource's ancestors are consulted up to system defaults. If this option is disabled and the feature is not set on the instance, it will be omitted from the response or Not Found is returned when the instance has no features flags at all."; + } + ]; +} + +message GetInstanceFeaturesResponse { + zitadel.object.v2beta.Details details = 1; + FeatureFlag login_default_org = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "true"; + description: "The login UI will use the settings of the default org (and not from the instance) if no organization context is set"; + } + ]; + + FeatureFlag oidc_trigger_introspection_projections = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "true"; + description: "Enable projection triggers during an introspection request. This can act as workaround if there are noticeable consistency issues in the introspection response but can have an impact on performance. We are planning to remove triggers for introspection requests in the future. Please raise an issue if you needed to enable this feature."; + } + ]; + + FeatureFlag oidc_legacy_introspection = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "true"; + description: "We have recently refactored the introspection endpoint for performance reasons. This feature can be used to rollback to the legacy implementation if unexpected bugs arise. Please raise an issue if you needed to enable this feature."; + } + ]; +} diff --git a/proto/zitadel/feature/v2beta/organization.proto b/proto/zitadel/feature/v2beta/organization.proto new file mode 100644 index 000000000..df27a8faf --- /dev/null +++ b/proto/zitadel/feature/v2beta/organization.proto @@ -0,0 +1,62 @@ +syntax = "proto3"; + +package zitadel.feature.v2beta; + +import "protoc-gen-openapiv2/options/annotations.proto"; +import "validate/validate.proto"; + +import "zitadel/object/v2beta/object.proto"; +import "zitadel/feature/v2beta/feature.proto"; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta;feature"; + +message SetOrganizationFeaturesRequest { + string organization_id = 1[ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"69629023906488334\""; + } + ]; +} + +message SetOrganizationFeaturesResponse { + zitadel.object.v2beta.Details details = 1; +} + +message ResetOrganizationFeaturesRequest { + string organization_id = 1[ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"69629023906488334\""; + } + ]; +} + +message ResetOrganizationFeaturesResponse { + zitadel.object.v2beta.Details details = 1; +} + +message GetOrganizationFeaturesRequest { + string organization_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"69629023906488334\""; + } + ]; + bool inheritance = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "true"; + description: "Inherit unset features from the resource owners. This option is recursive: if the flag is set, the resource's ancestors are consulted up to system defaults. If this option is disabled and the feature is not set on the organization, it will be omitted from the response or Not Found is returned when the organization has no features flags at all."; + } + ]; +} + +message GetOrganizationFeaturesResponse { + zitadel.object.v2beta.Details details = 1; +} diff --git a/proto/zitadel/feature/v2beta/system.proto b/proto/zitadel/feature/v2beta/system.proto new file mode 100644 index 000000000..07068d362 --- /dev/null +++ b/proto/zitadel/feature/v2beta/system.proto @@ -0,0 +1,70 @@ +syntax = "proto3"; + +package zitadel.feature.v2beta; + +import "protoc-gen-openapiv2/options/annotations.proto"; +import "validate/validate.proto"; + +import "zitadel/object/v2beta/object.proto"; +import "zitadel/feature/v2beta/feature.proto"; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta;feature"; + +message SetSystemFeaturesRequest{ + optional bool login_default_org = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "true"; + description: "The login UI will use the settings of the default org (and not from the instance) if no organization context is set"; + } + ]; + + optional bool oidc_trigger_introspection_projections = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "true"; + description: "Enable projection triggers during an introspection request. This can act as workaround if there are noticeable consistency issues in the introspection response but can have an impact on performance. We are planning to remove triggers for introspection requests in the future. Please raise an issue if you needed to enable this feature."; + } + ]; + + optional bool oidc_legacy_introspection = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "true"; + description: "We have recently refactored the introspection endpoint for performance reasons. This feature can be used to rollback to the legacy implementation if unexpected bugs arise. Please raise an issue if you needed to enable this feature."; + } + ]; +} + +message SetSystemFeaturesResponse { + zitadel.object.v2beta.Details details = 1; +} + +message ResetSystemFeaturesRequest {} + +message ResetSystemFeaturesResponse { + zitadel.object.v2beta.Details details = 1; +} + +message GetSystemFeaturesRequest {} + +message GetSystemFeaturesResponse { + zitadel.object.v2beta.Details details = 1; + FeatureFlag login_default_org = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "true"; + description: "The login UI will use the settings of the default org (and not from the instance) if no organization context is set"; + } + ]; + + FeatureFlag oidc_trigger_introspection_projections = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "true"; + description: "Enable projection triggers during an introspection request. This can act as workaround if there are noticeable consistency issues in the introspection response but can have an impact on performance. We are planning to remove triggers for introspection requests in the future. Please raise an issue if you needed to enable this feature."; + } + ]; + + FeatureFlag oidc_legacy_introspection = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "true"; + description: "We have recently refactored the introspection endpoint for performance reasons. This feature can be used to rollback to the legacy implementation if unexpected bugs arise. Please raise an issue if you needed to enable this feature."; + } + ]; +} diff --git a/proto/zitadel/feature/v2beta/user.proto b/proto/zitadel/feature/v2beta/user.proto new file mode 100644 index 000000000..d2d561322 --- /dev/null +++ b/proto/zitadel/feature/v2beta/user.proto @@ -0,0 +1,62 @@ +syntax = "proto3"; + +package zitadel.feature.v2beta; + +import "protoc-gen-openapiv2/options/annotations.proto"; +import "validate/validate.proto"; + +import "zitadel/object/v2beta/object.proto"; +import "zitadel/feature/v2beta/feature.proto"; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta;feature"; + +message SetUserFeatureRequest { + string user_id = 1[ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"69629023906488334\""; + } + ]; +} + +message SetUserFeaturesResponse { + zitadel.object.v2beta.Details details = 1; +} + +message ResetUserFeaturesRequest { + string user_id = 1[ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"69629023906488334\""; + } + ]; +} + +message ResetUserFeaturesResponse { + zitadel.object.v2beta.Details details = 1; +} + +message GetUserFeaturesRequest { + string user_id = 1[ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"69629023906488334\""; + } + ]; + bool inheritance = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "true"; + description: "Inherit unset features from the resource owners. This option is recursive: if the flag is set, the resource's ancestors are consulted up to system defaults. If this option is disabled and the feature is not set on the user, it will be ommitted from the response or Not Found is returned when the user has no features flags at all."; + } + ]; +} + +message GetUserFeaturesResponse { + zitadel.object.v2beta.Details details = 1; +}