feat(api): feature flags (#7356)

* feat(api): feature API proto definitions

* update proto based on discussion with @livio-a

* cleanup old feature flag stuff

* authz instance queries

* align defaults

* projection definitions

* define commands and event reducers

* implement system and instance setter APIs

* api getter implementation

* unit test repository package

* command unit tests

* unit test Get queries

* grpc converter unit tests

* migrate the V1 features

* migrate oidc to dynamic features

* projection unit test

* fix instance by host

* fix instance by id data type in sql

* fix linting errors

* add system projection test

* fix behavior inversion

* resolve proto file comments

* rename SystemDefaultLoginInstanceEventType to SystemLoginDefaultOrgEventType so it's consistent with the instance level event

* use write models and conditional set events

* system features integration tests

* instance features integration tests

* error on empty request

* documentation entry

* typo in feature.proto

* fix start unit tests

* solve linting error on key case switch

* remove system defaults after discussion with @eliobischof

* fix system feature projection

* resolve comments in defaults.yaml

---------

Co-authored-by: Livio Spring <livio.a@gmail.com>
This commit is contained in:
Tim Möhlmann 2024-02-28 10:55:54 +02:00 committed by GitHub
parent 2801167668
commit 26d1563643
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
79 changed files with 4580 additions and 868 deletions

View File

@ -334,11 +334,6 @@ OIDC:
Path: /oauth/v2/device_authorization # ZITADEL_OIDC_CUSTOMENDPOINTS_DEVICEAUTH_PATH Path: /oauth/v2/device_authorization # ZITADEL_OIDC_CUSTOMENDPOINTS_DEVICEAUTH_PATH
DefaultLoginURLV2: "/login?authRequest=" # ZITADEL_OIDC_DEFAULTLOGINURLV2 DefaultLoginURLV2: "/login?authRequest=" # ZITADEL_OIDC_DEFAULTLOGINURLV2
DefaultLogoutURLV2: "/logout?post_logout_redirect=" # ZITADEL_OIDC_DEFAULTLOGOUTURLV2 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 PublicKeyCacheMaxAge: 24h # ZITADEL_OIDC_PUBLICKEYCACHEMAXAGE
SAML: SAML:
@ -431,7 +426,6 @@ SystemAPIUsers:
# Configure the SystemAPIUsers by environment variable using JSON notation: # Configure the SystemAPIUsers by environment variable using JSON notation:
# ZITADEL_SYSTEMAPIUSERS='{"systemuser":{"Path":"/path/to/superuser/key.pem"},"systemuser2":{"KeyData":"<base64 encoded key>"}}' # ZITADEL_SYSTEMAPIUSERS='{"systemuser":{"Path":"/path/to/superuser/key.pem"},"systemuser2":{"KeyData":"<base64 encoded key>"}}'
#TODO: remove as soon as possible
SystemDefaults: SystemDefaults:
SecretGenerators: SecretGenerators:
PasswordSaltCost: 14 # ZITADEL_SYSTEMDEFAULTS_SECRETGENERATORS_PASSWORDSALTCOST PasswordSaltCost: 14 # ZITADEL_SYSTEMDEFAULTS_SECRETGENERATORS_PASSWORDSALTCOST
@ -833,8 +827,13 @@ DefaultInstance:
Greeting: Hello {{.DisplayName}}, 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. 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 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: Features:
- FeatureLoginDefaultOrg: true LoginDefaultOrg: true # ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINDEFAULTORG
# TriggerIntrospectionProjections: false # ZITADEL_DEFAULTINSTANCE_FEATURES_TRIGGERINTROSPECTIONPROJECTIONS
# LegacyIntrospection: false # ZITADEL_DEFAULTINSTANCE_FEATURES_LEGACYINTROSPECTION
Limits: Limits:
# AuditLogRetention limits the number of events that can be queried via the events API by their age. # 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. # A value of "0s" means that all events are available.
@ -910,7 +909,9 @@ InternalAuthZ:
- "system.debug.read" - "system.debug.read"
- "system.debug.write" - "system.debug.write"
- "system.debug.delete" - "system.debug.delete"
- "system.feature.read"
- "system.feature.write" - "system.feature.write"
- "system.feature.delete"
- "system.limits.write" - "system.limits.write"
- "system.limits.delete" - "system.limits.delete"
- "system.quota.write" - "system.quota.write"
@ -921,6 +922,7 @@ InternalAuthZ:
- "system.instance.read" - "system.instance.read"
- "system.domain.read" - "system.domain.read"
- "system.debug.read" - "system.debug.read"
- "system.feature.read"
- "system.iam.member.read" - "system.iam.member.read"
- Role: "IAM_OWNER" - Role: "IAM_OWNER"
Permissions: Permissions:
@ -941,7 +943,9 @@ InternalAuthZ:
- "iam.flow.read" - "iam.flow.read"
- "iam.flow.write" - "iam.flow.write"
- "iam.flow.delete" - "iam.flow.delete"
- "iam.feature.read"
- "iam.feature.write" - "iam.feature.write"
- "iam.feature.delete"
- "iam.restrictions.read" - "iam.restrictions.read"
- "iam.restrictions.write" - "iam.restrictions.write"
- "org.read" - "org.read"
@ -961,6 +965,9 @@ InternalAuthZ:
- "org.flow.read" - "org.flow.read"
- "org.flow.write" - "org.flow.write"
- "org.flow.delete" - "org.flow.delete"
- "org.feature.read"
- "org.feature.write"
- "org.feature.delete"
- "user.read" - "user.read"
- "user.global.read" - "user.global.read"
- "user.write" - "user.write"
@ -971,6 +978,9 @@ InternalAuthZ:
- "user.membership.read" - "user.membership.read"
- "user.credential.write" - "user.credential.write"
- "user.passkey.write" - "user.passkey.write"
- "user.feature.read"
- "user.feature.write"
- "user.feature.delete"
- "policy.read" - "policy.read"
- "policy.write" - "policy.write"
- "policy.delete" - "policy.delete"
@ -1010,15 +1020,18 @@ InternalAuthZ:
- "iam.action.read" - "iam.action.read"
- "iam.flow.read" - "iam.flow.read"
- "iam.restrictions.read" - "iam.restrictions.read"
- "iam.feature.read"
- "org.read" - "org.read"
- "org.member.read" - "org.member.read"
- "org.idp.read" - "org.idp.read"
- "org.action.read" - "org.action.read"
- "org.flow.read" - "org.flow.read"
- "org.feature.read"
- "user.read" - "user.read"
- "user.global.read" - "user.global.read"
- "user.grant.read" - "user.grant.read"
- "user.membership.read" - "user.membership.read"
- "user.feature.read"
- "policy.read" - "policy.read"
- "project.read" - "project.read"
- "project.member.read" - "project.member.read"
@ -1047,6 +1060,9 @@ InternalAuthZ:
- "org.flow.read" - "org.flow.read"
- "org.flow.write" - "org.flow.write"
- "org.flow.delete" - "org.flow.delete"
- "org.feature.read"
- "org.feature.write"
- "org.feature.delete"
- "user.read" - "user.read"
- "user.global.read" - "user.global.read"
- "user.write" - "user.write"
@ -1057,6 +1073,9 @@ InternalAuthZ:
- "user.membership.read" - "user.membership.read"
- "user.credential.write" - "user.credential.write"
- "user.passkey.write" - "user.passkey.write"
- "user.feature.read"
- "user.feature.write"
- "user.feature.delete"
- "policy.read" - "policy.read"
- "policy.write" - "policy.write"
- "policy.delete" - "policy.delete"
@ -1095,6 +1114,9 @@ InternalAuthZ:
- "user.grant.delete" - "user.grant.delete"
- "user.membership.read" - "user.membership.read"
- "user.passkey.write" - "user.passkey.write"
- "user.feature.read"
- "user.feature.write"
- "user.feature.delete"
- "project.read" - "project.read"
- "project.member.read" - "project.member.read"
- "project.role.read" - "project.role.read"
@ -1122,6 +1144,9 @@ InternalAuthZ:
- "org.flow.read" - "org.flow.read"
- "org.flow.write" - "org.flow.write"
- "org.flow.delete" - "org.flow.delete"
- "org.feature.read"
- "org.feature.write"
- "org.feature.delete"
- "user.read" - "user.read"
- "user.global.read" - "user.global.read"
- "user.write" - "user.write"
@ -1132,6 +1157,9 @@ InternalAuthZ:
- "user.membership.read" - "user.membership.read"
- "user.credential.write" - "user.credential.write"
- "user.passkey.write" - "user.passkey.write"
- "user.feature.read"
- "user.feature.write"
- "user.feature.delete"
- "policy.read" - "policy.read"
- "policy.write" - "policy.write"
- "policy.delete" - "policy.delete"
@ -1165,6 +1193,9 @@ InternalAuthZ:
- "user.grant.write" - "user.grant.write"
- "user.grant.delete" - "user.grant.delete"
- "user.membership.read" - "user.membership.read"
- "user.feature.read"
- "user.feature.write"
- "user.feature.delete"
- "policy.read" - "policy.read"
- "project.read" - "project.read"
- "project.role.read" - "project.role.read"
@ -1176,10 +1207,12 @@ InternalAuthZ:
- "org.idp.read" - "org.idp.read"
- "org.action.read" - "org.action.read"
- "org.flow.read" - "org.flow.read"
- "org.feature.read"
- "user.read" - "user.read"
- "user.global.read" - "user.global.read"
- "user.grant.read" - "user.grant.read"
- "user.membership.read" - "user.membership.read"
- "user.feature.read"
- "policy.read" - "policy.read"
- "project.read" - "project.read"
- "project.member.read" - "project.member.read"
@ -1196,6 +1229,9 @@ InternalAuthZ:
- "org.idp.read" - "org.idp.read"
- "org.idp.write" - "org.idp.write"
- "org.idp.delete" - "org.idp.delete"
- "org.feature.read"
- "org.feature.write"
- "org.feature.delete"
- "policy.read" - "policy.read"
- "policy.write" - "policy.write"
- "policy.delete" - "policy.delete"

View File

@ -10,7 +10,6 @@ import (
internal_authz "github.com/zitadel/zitadel/internal/api/authz" internal_authz "github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/config/hook" "github.com/zitadel/zitadel/internal/config/hook"
"github.com/zitadel/zitadel/internal/config/network" "github.com/zitadel/zitadel/internal/config/network"
"github.com/zitadel/zitadel/internal/domain"
) )
type Config struct { type Config struct {
@ -27,7 +26,6 @@ func MustNewConfig(v *viper.Viper) *Config {
mapstructure.StringToTimeDurationHookFunc(), mapstructure.StringToTimeDurationHookFunc(),
mapstructure.StringToTimeHookFunc(time.RFC3339), mapstructure.StringToTimeHookFunc(time.RFC3339),
mapstructure.StringToSliceHookFunc(","), mapstructure.StringToSliceHookFunc(","),
hook.EnumHookFunc(domain.FeatureString),
hook.EnumHookFunc(internal_authz.MemberTypeString), hook.EnumHookFunc(internal_authz.MemberTypeString),
)), )),
) )

View File

@ -24,7 +24,7 @@ type FirstInstance struct {
Org command.InstanceOrgSetup Org command.InstanceOrgSetup
MachineKeyPath string MachineKeyPath string
PatPath string PatPath string
Features map[domain.Feature]any Features *command.InstanceFeatures
instanceSetup command.InstanceSetup instanceSetup command.InstanceSetup
userEncryptionKey *crypto.KeyConfig userEncryptionKey *crypto.KeyConfig

View File

@ -19,7 +19,6 @@ import (
"github.com/zitadel/zitadel/internal/config/hook" "github.com/zitadel/zitadel/internal/config/hook"
"github.com/zitadel/zitadel/internal/config/systemdefaults" "github.com/zitadel/zitadel/internal/config/systemdefaults"
"github.com/zitadel/zitadel/internal/database" "github.com/zitadel/zitadel/internal/database"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/id" "github.com/zitadel/zitadel/internal/id"
"github.com/zitadel/zitadel/internal/notification/handlers" "github.com/zitadel/zitadel/internal/notification/handlers"
@ -67,7 +66,6 @@ func MustNewConfig(v *viper.Viper) *Config {
mapstructure.StringToTimeHookFunc(time.RFC3339), mapstructure.StringToTimeHookFunc(time.RFC3339),
mapstructure.StringToSliceHookFunc(","), mapstructure.StringToSliceHookFunc(","),
database.DecodeHook, database.DecodeHook,
hook.EnumHookFunc(domain.FeatureString),
hook.EnumHookFunc(authz.MemberTypeString), hook.EnumHookFunc(authz.MemberTypeString),
actions.HTTPConfigDecodeHook, actions.HTTPConfigDecodeHook,
hooks.MapTypeStringDecode[string, *authz.SystemAPIUser], hooks.MapTypeStringDecode[string, *authz.SystemAPIUser],
@ -127,7 +125,6 @@ func MustNewSteps(v *viper.Viper) *Steps {
mapstructure.StringToTimeDurationHookFunc(), mapstructure.StringToTimeDurationHookFunc(),
mapstructure.StringToTimeHookFunc(time.RFC3339), mapstructure.StringToTimeHookFunc(time.RFC3339),
mapstructure.StringToSliceHookFunc(","), mapstructure.StringToSliceHookFunc(","),
hook.EnumHookFunc(domain.FeatureString),
)), )),
) )
logging.OnError(err).Fatal("unable to read steps") logging.OnError(err).Fatal("unable to read steps")

View File

@ -97,7 +97,6 @@ func MustNewConfig(v *viper.Viper) *Config {
mapstructure.StringToSliceHookFunc(","), mapstructure.StringToSliceHookFunc(","),
database.DecodeHook, database.DecodeHook,
actions.HTTPConfigDecodeHook, actions.HTTPConfigDecodeHook,
hook.EnumHookFunc(domain.FeatureString),
hook.EnumHookFunc(internal_authz.MemberTypeString), hook.EnumHookFunc(internal_authz.MemberTypeString),
)), )),
) )

View File

@ -8,12 +8,14 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/muhlemmer/gu"
"github.com/spf13/viper" "github.com/spf13/viper"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/zitadel/zitadel/internal/actions" "github.com/zitadel/zitadel/internal/actions"
"github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/domain"
) )
@ -70,7 +72,9 @@ Log:
args: args{yaml: ` args: args{yaml: `
DefaultInstance: DefaultInstance:
Features: Features:
- FeatureLoginDefaultOrg: true LoginDefaultOrg: true
LegacyIntrospection: true
TriggerIntrospectionProjections: true
Log: Log:
Level: info Level: info
Actions: Actions:
@ -78,25 +82,10 @@ Actions:
DenyList: [] DenyList: []
`}, `},
want: func(t *testing.T, config *Config) { want: func(t *testing.T, config *Config) {
assert.Equal(t, config.DefaultInstance.Features, map[domain.Feature]any{ assert.Equal(t, config.DefaultInstance.Features, &command.InstanceFeatures{
domain.FeatureLoginDefaultOrg: true, LoginDefaultOrg: gu.Ptr(true),
}) LegacyIntrospection: gu.Ptr(true),
}, TriggerIntrospectionProjections: gu.Ptr(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,
}) })
}, },
}, { }, {

View File

@ -29,7 +29,6 @@ import (
"github.com/zitadel/zitadel/cmd/encryption" "github.com/zitadel/zitadel/cmd/encryption"
"github.com/zitadel/zitadel/cmd/key" "github.com/zitadel/zitadel/cmd/key"
cmd_tls "github.com/zitadel/zitadel/cmd/tls" cmd_tls "github.com/zitadel/zitadel/cmd/tls"
"github.com/zitadel/zitadel/feature"
"github.com/zitadel/zitadel/internal/actions" "github.com/zitadel/zitadel/internal/actions"
admin_es "github.com/zitadel/zitadel/internal/admin/repository/eventsourcing" admin_es "github.com/zitadel/zitadel/internal/admin/repository/eventsourcing"
"github.com/zitadel/zitadel/internal/api" "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/admin"
"github.com/zitadel/zitadel/internal/api/grpc/auth" "github.com/zitadel/zitadel/internal/api/grpc/auth"
execution_v3_alpha "github.com/zitadel/zitadel/internal/api/grpc/execution/v3alpha" 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" "github.com/zitadel/zitadel/internal/api/grpc/management"
oidc_v2 "github.com/zitadel/zitadel/internal/api/grpc/oidc/v2" oidc_v2 "github.com/zitadel/zitadel/internal/api/grpc/oidc/v2"
"github.com/zitadel/zitadel/internal/api/grpc/org/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 { if err := apis.RegisterService(ctx, org.CreateServer(commands, queries, permissionCheck)); err != nil {
return nil, err 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 { if err := apis.RegisterService(ctx, execution_v3_alpha.CreateServer(commands, queries, domain.AllFunctions, apis.ListGrpcMethods, apis.ListGrpcServices)); err != nil {
return nil, err return nil, err
} }
@ -469,7 +472,6 @@ func startAPIs(
keys.User, keys.User,
keys.IDPConfig, keys.IDPConfig,
keys.CSRFCookieKey, keys.CSRFCookieKey,
feature.NewCheck(eventstore),
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("unable to start login: %w", err) return nil, fmt.Errorf("unable to start login: %w", err)

View File

@ -311,6 +311,13 @@ module.exports = {
groupPathsBy: "tag", groupPathsBy: "tag",
}, },
}, },
feature_v2: {
specPath: ".artifacts/openapi/zitadel/feature/v2beta/feature_service.swagger.json",
outputDir: "docs/apis/resources/feature_service_v2",
sidebarOptions: {
groupPathsBy: "tag",
},
},
}, },
}, },
], ],

View File

@ -694,6 +694,20 @@ module.exports = {
}, },
items: require("./docs/apis/resources/settings_service/sidebar.js"), 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"),
},
] ]
}, },
{ {

View File

@ -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
}

View File

@ -5,6 +5,8 @@ import (
"time" "time"
"golang.org/x/text/language" "golang.org/x/text/language"
"github.com/zitadel/zitadel/internal/feature"
) )
var ( var (
@ -23,6 +25,7 @@ type Instance interface {
SecurityPolicyAllowedOrigins() []string SecurityPolicyAllowedOrigins() []string
Block() *bool Block() *bool
AuditLogRetention() *time.Duration AuditLogRetention() *time.Duration
Features() feature.Features
} }
type InstanceVerifier interface { type InstanceVerifier interface {
@ -37,6 +40,7 @@ type instance struct {
appID string appID string
clientID string clientID string
orgID string orgID string
features feature.Features
} }
func (i *instance) Block() *bool { func (i *instance) Block() *bool {
@ -83,6 +87,10 @@ func (i *instance) SecurityPolicyAllowedOrigins() []string {
return nil return nil
} }
func (i *instance) Features() feature.Features {
return i.features
}
func GetInstance(ctx context.Context) Instance { func GetInstance(ctx context.Context) Instance {
instance, ok := ctx.Value(instanceKey).(Instance) instance, ok := ctx.Value(instanceKey).(Instance)
if !ok { if !ok {
@ -91,6 +99,10 @@ func GetInstance(ctx context.Context) Instance {
return instance return instance
} }
func GetFeatures(ctx context.Context) feature.Features {
return GetInstance(ctx).Features()
}
func WithInstance(ctx context.Context, instance Instance) context.Context { func WithInstance(ctx context.Context, instance Instance) context.Context {
return context.WithValue(ctx, instanceKey, instance) return context.WithValue(ctx, instanceKey, instance)
} }
@ -120,3 +132,12 @@ func WithConsole(ctx context.Context, projectID, appID string) context.Context {
//i.clientID = clientID //i.clientID = clientID
return context.WithValue(ctx, instanceKey, i) 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)
}

View File

@ -7,6 +7,8 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"golang.org/x/text/language" "golang.org/x/text/language"
"github.com/zitadel/zitadel/internal/feature"
) )
func Test_Instance(t *testing.T) { func Test_Instance(t *testing.T) {
@ -17,6 +19,7 @@ func Test_Instance(t *testing.T) {
instanceID string instanceID string
projectID string projectID string
consoleID string consoleID string
features feature.Features
} }
tests := []struct { tests := []struct {
name string name string
@ -56,6 +59,19 @@ func Test_Instance(t *testing.T) {
consoleID: "consoleID", consoleID: "consoleID",
}, },
}, },
{
"WithFeatures",
args{
WithFeatures(context.Background(), feature.Features{
LoginDefaultOrg: true,
}),
},
res{
features: feature.Features{
LoginDefaultOrg: true,
},
},
},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { 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.instanceID, got.InstanceID())
assert.Equal(t, tt.res.projectID, got.ProjectID()) assert.Equal(t, tt.res.projectID, got.ProjectID())
assert.Equal(t, tt.res.consoleID, got.ConsoleClientID()) 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 { func (m *mockInstance) SecurityPolicyAllowedOrigins() []string {
return nil return nil
} }
func (m *mockInstance) Features() feature.Features {
return feature.Features{}
}

View File

@ -3,13 +3,17 @@ package admin
import ( import (
"context" "context"
"github.com/muhlemmer/gu"
object_pb "github.com/zitadel/zitadel/internal/api/grpc/object" 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" admin_pb "github.com/zitadel/zitadel/pkg/grpc/admin"
) )
func (s *Server) ActivateFeatureLoginDefaultOrg(ctx context.Context, _ *admin_pb.ActivateFeatureLoginDefaultOrgRequest) (*admin_pb.ActivateFeatureLoginDefaultOrgResponse, error) { 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 { if err != nil {
return nil, err return nil, err
} }

View File

@ -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)
}
}

View File

@ -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: &timestamppb.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: &timestamppb.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)
})
}
}

View File

@ -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")
}

View File

@ -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")
}

View File

@ -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
}

View File

@ -22,9 +22,9 @@ func InstanceToPb(instance *query.Instance) *instance_pb.Instance {
instance.Sequence, instance.Sequence,
instance.CreationDate, instance.CreationDate,
instance.ChangeDate, instance.ChangeDate,
instance.InstanceID(), instance.ID,
), ),
Id: instance.InstanceID(), Id: instance.ID,
Name: instance.Name, Name: instance.Name,
Domains: DomainsToPb(instance.Domains), Domains: DomainsToPb(instance.Domains),
Version: build.Version(), Version: build.Version(),
@ -38,9 +38,9 @@ func InstanceDetailToPb(instance *query.Instance) *instance_pb.InstanceDetail {
instance.Sequence, instance.Sequence,
instance.CreationDate, instance.CreationDate,
instance.ChangeDate, instance.ChangeDate,
instance.InstanceID(), instance.ID,
), ),
Id: instance.InstanceID(), Id: instance.ID,
Name: instance.Name, Name: instance.Name,
Domains: DomainsToPb(instance.Domains), Domains: DomainsToPb(instance.Domains),
Version: build.Version(), Version: build.Version(),

View File

@ -12,6 +12,7 @@ import (
"google.golang.org/grpc/metadata" "google.golang.org/grpc/metadata"
"github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/feature"
) )
func Test_hostNameFromContext(t *testing.T) { func Test_hostNameFromContext(t *testing.T) {
@ -208,3 +209,7 @@ func (m *mockInstance) RequestedHost() string {
func (m *mockInstance) SecurityPolicyAllowedOrigins() []string { func (m *mockInstance) SecurityPolicyAllowedOrigins() []string {
return nil return nil
} }
func (m *mockInstance) Features() feature.Features {
return feature.Features{}
}

View File

@ -4,6 +4,7 @@ import (
"context" "context"
object_pb "github.com/zitadel/zitadel/internal/api/grpc/object" 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/domain"
"github.com/zitadel/zitadel/internal/zerrors" "github.com/zitadel/zitadel/internal/zerrors"
system_pb "github.com/zitadel/zitadel/pkg/grpc/system" 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) { func (s *Server) setInstanceFeature(ctx context.Context, req *system_pb.SetInstanceFeatureRequest) (*domain.ObjectDetails, error) {
feat := domain.Feature(req.FeatureId) feat := domain.Feature(req.FeatureId)
if !feat.IsAFeature() { if feat != domain.FeatureLoginDefaultOrg {
return nil, zerrors.ThrowInvalidArgument(nil, "SYST-SGV45", "Errors.Feature.NotExisting") return nil, zerrors.ThrowInvalidArgument(nil, "SYST-SGV45", "Errors.Feature.NotExisting")
} }
switch t := req.Value.(type) { switch t := req.Value.(type) {
case *system_pb.SetInstanceFeatureRequest_Bool: case *system_pb.SetInstanceFeatureRequest_Bool:
return s.command.SetBooleanInstanceFeature(ctx, feat, t.Bool) return s.command.SetInstanceFeatures(ctx, &command.InstanceFeatures{
LoginDefaultOrg: &t.Bool,
})
default: default:
return nil, zerrors.ThrowInvalidArgument(nil, "SYST-dag5g", "Errors.Feature.TypeNotSupported") return nil, zerrors.ThrowInvalidArgument(nil, "SYST-dag5g", "Errors.Feature.TypeNotSupported")
} }

View File

@ -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) { 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 { if err != nil {
return nil, err return nil, err
} }

View File

@ -14,6 +14,7 @@ import (
"github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/authz"
zitadel_http "github.com/zitadel/zitadel/internal/api/http" zitadel_http "github.com/zitadel/zitadel/internal/api/http"
"github.com/zitadel/zitadel/internal/feature"
) )
func Test_instanceInterceptor_Handler(t *testing.T) { func Test_instanceInterceptor_Handler(t *testing.T) {
@ -343,3 +344,7 @@ func (m *mockInstance) RequestedHost() string {
func (m *mockInstance) SecurityPolicyAllowedOrigins() []string { func (m *mockInstance) SecurityPolicyAllowedOrigins() []string {
return nil return nil
} }
func (m *mockInstance) Features() feature.Features {
return feature.Features{}
}

View File

@ -9,6 +9,7 @@ import (
"github.com/zitadel/oidc/v3/pkg/oidc" "github.com/zitadel/oidc/v3/pkg/oidc"
"github.com/zitadel/oidc/v3/pkg/op" "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/command"
"github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/internal/user/model" "github.com/zitadel/zitadel/internal/user/model"
@ -95,7 +96,7 @@ func (s *Server) assertClientScopesForPAT(ctx context.Context, token *accessToke
if err != nil { if err != nil {
return zerrors.ThrowInternal(err, "OIDC-Cyc78", "Errors.Internal") 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 { if err != nil {
return err return err
} }

View File

@ -10,6 +10,7 @@ import (
"github.com/zitadel/oidc/v3/pkg/oidc" "github.com/zitadel/oidc/v3/pkg/oidc"
"github.com/zitadel/oidc/v3/pkg/op" "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/crypto"
"github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/internal/telemetry/tracing" "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) span.EndWithError(err)
}() }()
if s.features.LegacyIntrospection { features := authz.GetFeatures(ctx)
if features.LegacyIntrospection {
return s.LegacyServer.Introspect(ctx, r) return s.LegacyServer.Introspect(ctx, r)
} }
if s.features.TriggerIntrospectionProjections { if features.TriggerIntrospectionProjections {
// Execute all triggers in one concurrent sweep. // Execute all triggers in one concurrent sweep.
query.TriggerIntrospectionProjections(ctx) query.TriggerIntrospectionProjections(ctx)
} }

View File

@ -42,7 +42,6 @@ type Config struct {
DeviceAuth *DeviceAuthorizationConfig DeviceAuth *DeviceAuthorizationConfig
DefaultLoginURLV2 string DefaultLoginURLV2 string
DefaultLogoutURLV2 string DefaultLogoutURLV2 string
Features Features
PublicKeyCacheMaxAge time.Duration PublicKeyCacheMaxAge time.Duration
} }
@ -62,11 +61,6 @@ type Endpoint struct {
URL string URL string
} }
type Features struct {
TriggerIntrospectionProjections bool
LegacyIntrospection bool
}
type OPStorage struct { type OPStorage struct {
repo repository.Repository repo repository.Repository
command *command.Commands command *command.Commands
@ -128,7 +122,6 @@ func NewServer(
server := &Server{ server := &Server{
LegacyServer: op.NewLegacyServer(provider, endpoints(config.CustomEndpoints)), LegacyServer: op.NewLegacyServer(provider, endpoints(config.CustomEndpoints)),
features: config.Features,
repo: repo, repo: repo,
query: query, query: query,
command: command, command: command,

View File

@ -21,7 +21,6 @@ import (
type Server struct { type Server struct {
http.Handler http.Handler
*op.LegacyServer *op.LegacyServer
features Features
repo repository.Repository repo repository.Repository
query *query.Queries query *query.Queries

View File

@ -9,7 +9,6 @@ import (
"github.com/gorilla/csrf" "github.com/gorilla/csrf"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/zitadel/zitadel/feature"
"github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/authz"
http_utils "github.com/zitadel/zitadel/internal/api/http" http_utils "github.com/zitadel/zitadel/internal/api/http"
"github.com/zitadel/zitadel/internal/api/http/middleware" "github.com/zitadel/zitadel/internal/api/http/middleware"
@ -39,7 +38,6 @@ type Login struct {
samlAuthCallbackURL func(context.Context, string) string samlAuthCallbackURL func(context.Context, string) string
idpConfigAlg crypto.EncryptionAlgorithm idpConfigAlg crypto.EncryptionAlgorithm
userCodeAlg crypto.EncryptionAlgorithm userCodeAlg crypto.EncryptionAlgorithm
featureCheck feature.Checker
} }
type Config struct { type Config struct {
@ -76,7 +74,6 @@ func CreateLogin(config Config,
userCodeAlg crypto.EncryptionAlgorithm, userCodeAlg crypto.EncryptionAlgorithm,
idpConfigAlg crypto.EncryptionAlgorithm, idpConfigAlg crypto.EncryptionAlgorithm,
csrfCookieKey []byte, csrfCookieKey []byte,
featureCheck feature.Checker,
) (*Login, error) { ) (*Login, error) {
login := &Login{ login := &Login{
oidcAuthCallbackURL: oidcAuthCallbackURL, oidcAuthCallbackURL: oidcAuthCallbackURL,
@ -89,7 +86,6 @@ func CreateLogin(config Config,
authRepo: authRepo, authRepo: authRepo,
idpConfigAlg: idpConfigAlg, idpConfigAlg: idpConfigAlg,
userCodeAlg: userCodeAlg, userCodeAlg: userCodeAlg,
featureCheck: featureCheck,
} }
csrfInterceptor := createCSRFInterceptor(config.CSRFCookieName, csrfCookieKey, externalSecure, login.csrfErrorHandler()) csrfInterceptor := createCSRFInterceptor(config.CSRFCookieName, csrfCookieKey, externalSecure, login.csrfErrorHandler())
cacheInterceptor := createCacheInterceptor(config.Cache.MaxAge, config.Cache.SharedMaxAge, assetCache) cacheInterceptor := createCacheInterceptor(config.Cache.MaxAge, config.Cache.SharedMaxAge, assetCache)

View File

@ -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 { func (l *Login) getPrivateLabelingID(r *http.Request, authReq *domain.AuthRequest) string {
defaultID := authz.GetInstance(r.Context()).DefaultOrganisationID() instance := authz.GetInstance(r.Context())
f, err := l.featureCheck.CheckInstanceBooleanFeature(r.Context(), domain.FeatureLoginDefaultOrg) defaultID := instance.DefaultOrganisationID()
logging.OnError(err).Warnf("could not check feature %s", domain.FeatureLoginDefaultOrg) if !instance.Features().LoginDefaultOrg {
if !f.Boolean { defaultID = instance.InstanceID()
defaultID = authz.GetInstance(r.Context()).InstanceID()
} }
if authReq != nil { if authReq != nil {
return authReq.PrivateLabelingOrgID(defaultID) return authReq.PrivateLabelingOrgID(defaultID)
} }

View File

@ -7,7 +7,6 @@ import (
"github.com/zitadel/logging" "github.com/zitadel/logging"
"github.com/zitadel/zitadel/feature"
"github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/auth/repository/eventsourcing/view" "github.com/zitadel/zitadel/internal/auth/repository/eventsourcing/view"
cache "github.com/zitadel/zitadel/internal/auth_request/repository" cache "github.com/zitadel/zitadel/internal/auth_request/repository"
@ -50,8 +49,6 @@ type AuthRequestRepo struct {
ApplicationProvider applicationProvider ApplicationProvider applicationProvider
CustomTextProvider customTextProvider CustomTextProvider customTextProvider
FeatureCheck feature.Checker
IdGenerator id.Generator 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 { func (repo *AuthRequestRepo) fillPolicies(ctx context.Context, request *domain.AuthRequest) error {
instance := authz.GetInstance(ctx)
orgID := request.RequestedOrgID orgID := request.RequestedOrgID
if orgID == "" { if orgID == "" {
orgID = request.UserOrgID orgID = request.UserOrgID
} }
if orgID == "" { if orgID == "" {
orgID = authz.GetInstance(ctx).DefaultOrganisationID() orgID = authz.GetInstance(ctx).DefaultOrganisationID()
f, err := repo.FeatureCheck.CheckInstanceBooleanFeature(ctx, domain.FeatureLoginDefaultOrg) if !instance.Features().LoginDefaultOrg {
logging.WithFields("authReq", request.ID).OnError(err).Warnf("could not check feature %s", domain.FeatureLoginDefaultOrg) orgID = instance.InstanceID()
if !f.Boolean {
orgID = authz.GetInstance(ctx).InstanceID()
} }
} }
@ -692,7 +688,7 @@ func (repo *AuthRequestRepo) fillPolicies(ctx context.Context, request *domain.A
return err return err
} }
request.LabelPolicy = labelPolicy request.LabelPolicy = labelPolicy
defaultLoginTranslations, err := repo.getLoginTexts(ctx, authz.GetInstance(ctx).InstanceID()) defaultLoginTranslations, err := repo.getLoginTexts(ctx, instance.InstanceID())
if err != nil { if err != nil {
return err return err
} }

View File

@ -3,7 +3,6 @@ package eventsourcing
import ( import (
"context" "context"
"github.com/zitadel/zitadel/feature"
"github.com/zitadel/zitadel/internal/auth/repository/eventsourcing/eventstore" "github.com/zitadel/zitadel/internal/auth/repository/eventsourcing/eventstore"
auth_handler "github.com/zitadel/zitadel/internal/auth/repository/eventsourcing/handler" auth_handler "github.com/zitadel/zitadel/internal/auth/repository/eventsourcing/handler"
auth_view "github.com/zitadel/zitadel/internal/auth/repository/eventsourcing/view" 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, ProjectProvider: queryView,
ApplicationProvider: queries, ApplicationProvider: queries,
CustomTextProvider: queries, CustomTextProvider: queries,
FeatureCheck: feature.NewCheck(esV2),
IdGenerator: id.SonyFlakeGenerator(), IdGenerator: id.SonyFlakeGenerator(),
}, },
eventstore.TokenRepo{ eventstore.TokenRepo{

View File

@ -15,7 +15,6 @@ import (
"github.com/zitadel/zitadel/internal/i18n" "github.com/zitadel/zitadel/internal/i18n"
"github.com/zitadel/zitadel/internal/id" "github.com/zitadel/zitadel/internal/id"
"github.com/zitadel/zitadel/internal/notification/channels/smtp" "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/instance"
"github.com/zitadel/zitadel/internal/repository/limits" "github.com/zitadel/zitadel/internal/repository/limits"
"github.com/zitadel/zitadel/internal/repository/org" "github.com/zitadel/zitadel/internal/repository/org"
@ -110,7 +109,7 @@ type InstanceSetup struct {
SMTPConfiguration *smtp.Config SMTPConfiguration *smtp.Config
OIDCSettings *OIDCSettings OIDCSettings *OIDCSettings
Quotas *SetQuotas Quotas *SetQuotas
Features map[domain.Feature]any Features *InstanceFeatures
Limits *SetLimits Limits *SetLimits
Restrictions *SetRestrictions Restrictions *SetRestrictions
} }
@ -313,9 +312,7 @@ func (c *Commands) SetUpInstance(ctx context.Context, setup *InstanceSetup) (str
setupCustomDomain(c, &validations, instanceAgg, setup.CustomDomain) setupCustomDomain(c, &validations, instanceAgg, setup.CustomDomain)
setupSMTPSettings(c, &validations, setup.SMTPConfiguration, instanceAgg) setupSMTPSettings(c, &validations, setup.SMTPConfiguration, instanceAgg)
setupOIDCSettings(c, &validations, setup.OIDCSettings, instanceAgg) setupOIDCSettings(c, &validations, setup.OIDCSettings, instanceAgg)
if err := setupFeatures(c, &validations, setup.Features, instanceID); err != nil { setupFeatures(&validations, setup.Features, instanceID)
return "", "", nil, nil, err
}
setupLimits(c, &validations, limitsAgg, setup.Limits) setupLimits(c, &validations, limitsAgg, setup.Limits)
setupRestrictions(c, &validations, restrictionsAgg, setup.Restrictions) setupRestrictions(c, &validations, restrictionsAgg, setup.Restrictions)
@ -368,20 +365,8 @@ func setupQuotas(commands *Commands, validations *[]preparation.Validation, setQ
return nil return nil
} }
func setupFeatures(commands *Commands, validations *[]preparation.Validation, enableFeatures map[domain.Feature]any, instanceID string) error { func setupFeatures(validations *[]preparation.Validation, features *InstanceFeatures, instanceID string) {
for f, value := range enableFeatures { *validations = append(*validations, prepareSetFeatures(instanceID, features))
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 setupOIDCSettings(commands *Commands, validations *[]preparation.Validation, oidcSettings *OIDCSettings, instanceAgg *instance.Aggregate) { func setupOIDCSettings(commands *Commands, validations *[]preparation.Validation, oidcSettings *OIDCSettings, instanceAgg *instance.Aggregate) {

View File

@ -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
}
}

View File

@ -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
}

View File

@ -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)
})
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
})
}
}

View File

@ -17,6 +17,7 @@ import (
"github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/eventstore/repository" "github.com/zitadel/zitadel/internal/eventstore/repository"
"github.com/zitadel/zitadel/internal/eventstore/repository/mock" "github.com/zitadel/zitadel/internal/eventstore/repository/mock"
"github.com/zitadel/zitadel/internal/feature"
"github.com/zitadel/zitadel/internal/zerrors" "github.com/zitadel/zitadel/internal/zerrors"
) )
@ -215,6 +216,10 @@ func (m *mockInstance) SecurityPolicyAllowedOrigins() []string {
return nil return nil
} }
func (m *mockInstance) Features() feature.Features {
return feature.Features{}
}
func newMockPermissionCheckAllowed() domain.PermissionCheck { func newMockPermissionCheckAllowed() domain.PermissionCheck {
return func(ctx context.Context, permission, orgID, resourceID string) (err error) { return func(ctx context.Context, permission, orgID, resourceID string) (err error) {
return nil return nil

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
})
}
}

View File

@ -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
}

View File

@ -1,6 +1,8 @@
package domain package domain
import "time" import (
"time"
)
type ObjectDetails struct { type ObjectDetails struct {
Sequence uint64 Sequence uint64

View File

@ -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 // 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 { func NewTextArrayContainsCond(column string, value string) Condition {
return func(param string) (string, []any) { return func(param string) (string, []any) {

View File

@ -44,7 +44,6 @@ func (rm *ReadModel) Reduce() error {
rm.ChangeDate = rm.Events[len(rm.Events)-1].CreatedAt() rm.ChangeDate = rm.Events[len(rm.Events)-1].CreatedAt()
rm.ProcessedSequence = rm.Events[len(rm.Events)-1].Sequence() rm.ProcessedSequence = rm.Events[len(rm.Events)-1].Sequence()
// all events processed and not needed anymore // all events processed and not needed anymore
rm.Events = nil rm.Events = rm.Events[0:0]
rm.Events = []Event{}
return nil return nil
} }

View File

@ -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"`
}

View File

@ -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())
})
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -27,6 +27,7 @@ import (
"github.com/zitadel/zitadel/pkg/grpc/admin" "github.com/zitadel/zitadel/pkg/grpc/admin"
"github.com/zitadel/zitadel/pkg/grpc/auth" "github.com/zitadel/zitadel/pkg/grpc/auth"
execution "github.com/zitadel/zitadel/pkg/grpc/execution/v3alpha" 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" mgmt "github.com/zitadel/zitadel/pkg/grpc/management"
object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2beta" oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2beta"
@ -49,6 +50,7 @@ type Client struct {
OrgV2 organisation.OrganizationServiceClient OrgV2 organisation.OrganizationServiceClient
System system.SystemServiceClient System system.SystemServiceClient
ExecutionV3 execution.ExecutionServiceClient ExecutionV3 execution.ExecutionServiceClient
FeatureV2 feature.FeatureServiceClient
} }
func newClient(cc *grpc.ClientConn) Client { func newClient(cc *grpc.ClientConn) Client {
@ -63,6 +65,7 @@ func newClient(cc *grpc.ClientConn) Client {
OrgV2: organisation.NewOrganizationServiceClient(cc), OrgV2: organisation.NewOrganizationServiceClient(cc),
System: system.NewSystemServiceClient(cc), System: system.NewSystemServiceClient(cc),
ExecutionV3: execution.NewExecutionServiceClient(cc), ExecutionV3: execution.NewExecutionServiceClient(cc),
FeatureV2: feature.NewFeatureServiceClient(cc),
} }
} }

View File

@ -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,
}
}

View File

@ -3,6 +3,8 @@ package query
import ( import (
"context" "context"
"database/sql" "database/sql"
_ "embed"
"encoding/json"
"errors" "errors"
"strings" "strings"
"time" "time"
@ -15,6 +17,7 @@ import (
"github.com/zitadel/zitadel/internal/api/call" "github.com/zitadel/zitadel/internal/api/call"
"github.com/zitadel/zitadel/internal/database" "github.com/zitadel/zitadel/internal/database"
"github.com/zitadel/zitadel/internal/eventstore/handler/v2" "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/query/projection"
"github.com/zitadel/zitadel/internal/telemetry/tracing" "github.com/zitadel/zitadel/internal/telemetry/tracing"
"github.com/zitadel/zitadel/internal/zerrors" "github.com/zitadel/zitadel/internal/zerrors"
@ -94,21 +97,12 @@ type Instance struct {
Sequence uint64 Sequence uint64
Name string Name string
DefaultOrgID string DefaultOrgID string
IAMProjectID string IAMProjectID string
ConsoleID string ConsoleID string
ConsoleAppID string ConsoleAppID string
DefaultLang language.Tag DefaultLang language.Tag
Domains []*InstanceDomain Domains []*InstanceDomain
host string
csp csp
block *bool
auditLogRetention *time.Duration
}
type csp struct {
enabled bool
allowedOrigins database.TextArray[string]
} }
type Instances struct { type Instances struct {
@ -116,53 +110,6 @@ type Instances struct {
Instances []*Instance 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 { type InstanceSearchQueries struct {
SearchRequest SearchRequest
Queries []SearchQuery Queries []SearchQuery
@ -224,7 +171,7 @@ func (q *Queries) Instance(ctx context.Context, shouldTriggerBulk bool) (instanc
traceSpan.EndWithError(err) 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{ query, args, err := stmt.Where(sq.Eq{
InstanceColumnID.identifier(): authz.GetInstance(ctx).InstanceID(), InstanceColumnID.identifier(): authz.GetInstance(ctx).InstanceID(),
}).ToSql() }).ToSql()
@ -239,28 +186,34 @@ func (q *Queries) Instance(ctx context.Context, shouldTriggerBulk bool) (instanc
return instance, err 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) ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }() defer func() { span.EndWithError(err) }()
stmt, scan := prepareAuthzInstanceQuery(ctx, q.client, host) domain := strings.Split(host, ":")[0] // remove possible port
host = strings.Split(host, ":")[0] //remove possible port instance, scan := scanAuthzInstance(host, domain)
query, args, err := stmt.Where(sq.Eq{ err = q.client.QueryRowContext(ctx, scan, instanceByDomainQuery, domain)
InstanceDomainDomainCol.identifier(): host, logging.OnError(err).WithField("host", host).WithField("domain", domain).Warn("instance by 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...)
return instance, err return instance, err
} }
func (q *Queries) InstanceByID(ctx context.Context) (_ authz.Instance, err error) { 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 { 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 { if err != nil {
return language.Und return language.Und
} }
return instance.DefaultLanguage() return instance.DefaultLang
}
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
}
} }
func prepareInstancesQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(sq.SelectBuilder) sq.SelectBuilder, func(*sql.Rows) (*Instances, error)) { 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( return sq.Select(
InstanceColumnID.identifier(), InstanceColumnID.identifier(),
InstanceColumnCreationDate.identifier(), InstanceColumnCreationDate.identifier(),
@ -441,7 +353,6 @@ func prepareInstanceDomainQuery(ctx context.Context, db prepareDatabase, host st
PlaceholderFormat(sq.Dollar), PlaceholderFormat(sq.Dollar),
func(rows *sql.Rows) (*Instance, error) { func(rows *sql.Rows) (*Instance, error) {
instance := &Instance{ instance := &Instance{
host: host,
Domains: make([]*InstanceDomain, 0), Domains: make([]*InstanceDomain, 0),
} }
lang := "" 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)) { type authzInstance struct {
return sq.Select( id string
InstanceColumnID.identifier(), iamProjectID string
InstanceColumnCreationDate.identifier(), consoleID string
InstanceColumnChangeDate.identifier(), consoleAppID string
InstanceColumnSequence.identifier(), host string
InstanceColumnName.identifier(), domain string
InstanceColumnDefaultOrgID.identifier(), defaultLang language.Tag
InstanceColumnProjectID.identifier(), defaultOrgID string
InstanceColumnConsoleID.identifier(), csp csp
InstanceColumnConsoleAppID.identifier(), block *bool
InstanceColumnDefaultLanguage.identifier(), auditLogRetention *time.Duration
InstanceDomainDomainCol.identifier(), features feature.Features
InstanceDomainIsPrimaryCol.identifier(), }
InstanceDomainIsGeneratedCol.identifier(),
InstanceDomainCreationDateCol.identifier(), type csp struct {
InstanceDomainChangeDateCol.identifier(), enabled bool
InstanceDomainSequenceCol.identifier(), allowedOrigins database.TextArray[string]
SecurityPolicyColumnEnabled.identifier(), }
SecurityPolicyColumnAllowedOrigins.identifier(),
LimitsColumnAuditLogRetention.identifier(), func (i *authzInstance) InstanceID() string {
LimitsColumnBlock.identifier(), return i.id
). }
From(instanceTable.identifier()).
LeftJoin(join(InstanceDomainInstanceIDCol, InstanceColumnID)). func (i *authzInstance) ProjectID() string {
LeftJoin(join(SecurityPolicyColumnInstanceID, InstanceColumnID)). return i.iamProjectID
LeftJoin(join(LimitsColumnInstanceID, InstanceColumnID) + db.Timetravel(call.Took(ctx))). }
PlaceholderFormat(sq.Dollar),
func(rows *sql.Rows) (*Instance, error) { func (i *authzInstance) ConsoleClientID() string {
instance := &Instance{ return i.consoleID
host: host, }
Domains: make([]*InstanceDomain, 0),
} func (i *authzInstance) ConsoleApplicationID() string {
lang := "" return i.consoleAppID
for rows.Next() { }
var (
domain sql.NullString func (i *authzInstance) RequestedDomain() string {
isPrimary sql.NullBool return strings.Split(i.host, ":")[0]
isGenerated sql.NullBool }
changeDate sql.NullTime
creationDate sql.NullTime func (i *authzInstance) RequestedHost() string {
sequence sql.NullInt64 return i.host
securityPolicyEnabled sql.NullBool }
auditLogRetention database.NullDuration
block sql.NullBool func (i *authzInstance) DefaultLanguage() language.Tag {
) return i.defaultLang
err := rows.Scan( }
&instance.ID,
&instance.CreationDate, func (i *authzInstance) DefaultOrganisationID() string {
&instance.ChangeDate, return i.defaultOrgID
&instance.Sequence, }
&instance.Name,
&instance.DefaultOrgID, func (i *authzInstance) SecurityPolicyAllowedOrigins() []string {
&instance.IAMProjectID, if !i.csp.enabled {
&instance.ConsoleID, return nil
&instance.ConsoleAppID, }
&lang, return i.csp.allowedOrigins
&domain, }
&isPrimary,
&isGenerated, func (i *authzInstance) Block() *bool {
&changeDate, return i.block
&creationDate, }
&sequence,
&securityPolicyEnabled, func (i *authzInstance) AuditLogRetention() *time.Duration {
&instance.csp.allowedOrigins, return i.auditLogRetention
&auditLogRetention, }
&block,
) func (i *authzInstance) Features() feature.Features {
if err != nil { return i.features
return nil, zerrors.ThrowInternal(err, "QUERY-d3fas", "Errors.Internal") }
}
if !domain.Valid { func scanAuthzInstance(host, domain string) (*authzInstance, func(row *sql.Row) error) {
continue instance := &authzInstance{
} host: host,
instance.Domains = append(instance.Domains, &InstanceDomain{ domain: domain,
CreationDate: creationDate.Time, }
ChangeDate: changeDate.Time, return instance, func(row *sql.Row) error {
Sequence: uint64(sequence.Int64), var (
Domain: domain.String, lang string
IsPrimary: isPrimary.Bool, securityPolicyEnabled sql.NullBool
IsGenerated: isGenerated.Bool, auditLogRetention database.NullDuration
InstanceID: instance.ID, block sql.NullBool
}) features []byte
if auditLogRetention.Valid { )
instance.auditLogRetention = &auditLogRetention.Duration err := row.Scan(
} &instance.id,
if block.Valid { &instance.defaultOrgID,
instance.block = &block.Bool &instance.iamProjectID,
} &instance.consoleID,
instance.csp.enabled = securityPolicyEnabled.Bool &instance.consoleAppID,
} &lang,
if instance.ID == "" { &securityPolicyEnabled,
return nil, zerrors.ThrowNotFound(nil, "QUERY-1kIjX", "Errors.IAM.NotFound") &instance.csp.allowedOrigins,
} &auditLogRetention,
instance.DefaultLang = language.Make(lang) &block,
if err := rows.Close(); err != nil { &features,
return nil, zerrors.ThrowInternal(err, "QUERY-Dfbe2", "Errors.Query.CloseRows") )
} if errors.Is(err, sql.ErrNoRows) {
return instance, nil 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
}
} }

View File

@ -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;

View File

@ -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;

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
})
}
}

View File

@ -12,33 +12,9 @@ import (
sq "github.com/Masterminds/squirrel" sq "github.com/Masterminds/squirrel"
"golang.org/x/text/language" "golang.org/x/text/language"
"github.com/zitadel/zitadel/internal/zerrors"
) )
var ( 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,` + instancesQuery = `SELECT f.count, f.id,` +
` projections.instances.creation_date,` + ` projections.instances.creation_date,` +
` projections.instances.change_date,` + ` projections.instances.change_date,` +
@ -93,76 +69,6 @@ func Test_InstancePrepares(t *testing.T) {
want want want want
object interface{} 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", name: "prepareInstancesQuery no result",
prepare: func(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Rows) (*Instances, error)) { prepare: func(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Rows) (*Instances, error)) {

View File

@ -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
}

View File

@ -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)
})
}
}

View File

@ -72,6 +72,8 @@ var (
QuotaProjection *quotaProjection QuotaProjection *quotaProjection
LimitsProjection *handler.Handler LimitsProjection *handler.Handler
RestrictionsProjection *handler.Handler RestrictionsProjection *handler.Handler
SystemFeatureProjection *handler.Handler
InstanceFeatureProjection *handler.Handler
) )
type projection interface { 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"])) QuotaProjection = newQuotaProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["quotas"]))
LimitsProjection = newLimitsProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["limits"])) LimitsProjection = newLimitsProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["limits"]))
RestrictionsProjection = newRestrictionsProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["restrictions"])) 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() newProjectionsList()
return nil return nil
} }
@ -257,5 +261,7 @@ func newProjectionsList() {
QuotaProjection.handler, QuotaProjection.handler,
LimitsProjection, LimitsProjection,
RestrictionsProjection, RestrictionsProjection,
SystemFeatureProjection,
InstanceFeatureProjection,
} }
} }

View File

@ -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
}

View File

@ -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)
})
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
})
}
}

View File

@ -13,18 +13,3 @@ const (
AggregateType = "feature" AggregateType = "feature"
AggregateVersion = "v1" 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,
},
}
}

View File

@ -1,3 +1,5 @@
// Package feature implements the v1 feature repository.
// DEPRECATED: use ./feature_v2 instead.
package feature package feature
import ( import (
@ -6,14 +8,22 @@ import (
"github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/repository/feature/feature_v2"
) )
var ( var (
DefaultLoginInstanceEventType = EventTypeFromFeature(domain.FeatureLoginDefaultOrg) DefaultLoginInstanceEventType = eventTypePrefix + eventstore.EventType(strings.ToLower("FeatureLoginDefaultOrg")) + setSuffix
) )
func EventTypeFromFeature(feature domain.Feature) eventstore.EventType { // DefaultLoginInstanceEventToV2 upgrades the SetEvent to a V2 SetEvent so that
return eventTypePrefix + eventstore.EventType(strings.ToLower(feature.String())) + setSuffix // 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 { type SetEvent[T SetEventType] struct {

View File

@ -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,
},
}
}

View File

@ -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]])
}

View File

@ -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,
}
}

View File

@ -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)
})
}
}

View File

@ -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.";
}
];
}

View File

@ -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";
}
};
};
}
}

View File

@ -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.";
}
];
}

View File

@ -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;
}

View File

@ -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.";
}
];
}

View File

@ -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;
}