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
DefaultLoginURLV2: "/login?authRequest=" # ZITADEL_OIDC_DEFAULTLOGINURLV2
DefaultLogoutURLV2: "/logout?post_logout_redirect=" # ZITADEL_OIDC_DEFAULTLOGOUTURLV2
Features:
# Wheter projection triggers are used in the new Introspection implementation.
TriggerIntrospectionProjections: false
# Allows fallback to the Legacy Introspection implementation
LegacyIntrospection: false
PublicKeyCacheMaxAge: 24h # ZITADEL_OIDC_PUBLICKEYCACHEMAXAGE
SAML:
@ -431,7 +426,6 @@ SystemAPIUsers:
# Configure the SystemAPIUsers by environment variable using JSON notation:
# ZITADEL_SYSTEMAPIUSERS='{"systemuser":{"Path":"/path/to/superuser/key.pem"},"systemuser2":{"KeyData":"<base64 encoded key>"}}'
#TODO: remove as soon as possible
SystemDefaults:
SecretGenerators:
PasswordSaltCost: 14 # ZITADEL_SYSTEMDEFAULTS_SECRETGENERATORS_PASSWORDSALTCOST
@ -833,8 +827,13 @@ DefaultInstance:
Greeting: Hello {{.DisplayName}},
Text: The password of your user has changed. If this change was not done by you, please be advised to immediately reset your password.
ButtonText: Login
# Once a feature is set on the instance (true or false), system level feature settings
# will be ignored until instance level features are reset.
Features:
- FeatureLoginDefaultOrg: true
LoginDefaultOrg: true # ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINDEFAULTORG
# TriggerIntrospectionProjections: false # ZITADEL_DEFAULTINSTANCE_FEATURES_TRIGGERINTROSPECTIONPROJECTIONS
# LegacyIntrospection: false # ZITADEL_DEFAULTINSTANCE_FEATURES_LEGACYINTROSPECTION
Limits:
# AuditLogRetention limits the number of events that can be queried via the events API by their age.
# A value of "0s" means that all events are available.
@ -910,7 +909,9 @@ InternalAuthZ:
- "system.debug.read"
- "system.debug.write"
- "system.debug.delete"
- "system.feature.read"
- "system.feature.write"
- "system.feature.delete"
- "system.limits.write"
- "system.limits.delete"
- "system.quota.write"
@ -921,6 +922,7 @@ InternalAuthZ:
- "system.instance.read"
- "system.domain.read"
- "system.debug.read"
- "system.feature.read"
- "system.iam.member.read"
- Role: "IAM_OWNER"
Permissions:
@ -941,7 +943,9 @@ InternalAuthZ:
- "iam.flow.read"
- "iam.flow.write"
- "iam.flow.delete"
- "iam.feature.read"
- "iam.feature.write"
- "iam.feature.delete"
- "iam.restrictions.read"
- "iam.restrictions.write"
- "org.read"
@ -961,6 +965,9 @@ InternalAuthZ:
- "org.flow.read"
- "org.flow.write"
- "org.flow.delete"
- "org.feature.read"
- "org.feature.write"
- "org.feature.delete"
- "user.read"
- "user.global.read"
- "user.write"
@ -971,6 +978,9 @@ InternalAuthZ:
- "user.membership.read"
- "user.credential.write"
- "user.passkey.write"
- "user.feature.read"
- "user.feature.write"
- "user.feature.delete"
- "policy.read"
- "policy.write"
- "policy.delete"
@ -1010,15 +1020,18 @@ InternalAuthZ:
- "iam.action.read"
- "iam.flow.read"
- "iam.restrictions.read"
- "iam.feature.read"
- "org.read"
- "org.member.read"
- "org.idp.read"
- "org.action.read"
- "org.flow.read"
- "org.feature.read"
- "user.read"
- "user.global.read"
- "user.grant.read"
- "user.membership.read"
- "user.feature.read"
- "policy.read"
- "project.read"
- "project.member.read"
@ -1047,6 +1060,9 @@ InternalAuthZ:
- "org.flow.read"
- "org.flow.write"
- "org.flow.delete"
- "org.feature.read"
- "org.feature.write"
- "org.feature.delete"
- "user.read"
- "user.global.read"
- "user.write"
@ -1057,6 +1073,9 @@ InternalAuthZ:
- "user.membership.read"
- "user.credential.write"
- "user.passkey.write"
- "user.feature.read"
- "user.feature.write"
- "user.feature.delete"
- "policy.read"
- "policy.write"
- "policy.delete"
@ -1095,6 +1114,9 @@ InternalAuthZ:
- "user.grant.delete"
- "user.membership.read"
- "user.passkey.write"
- "user.feature.read"
- "user.feature.write"
- "user.feature.delete"
- "project.read"
- "project.member.read"
- "project.role.read"
@ -1122,6 +1144,9 @@ InternalAuthZ:
- "org.flow.read"
- "org.flow.write"
- "org.flow.delete"
- "org.feature.read"
- "org.feature.write"
- "org.feature.delete"
- "user.read"
- "user.global.read"
- "user.write"
@ -1132,6 +1157,9 @@ InternalAuthZ:
- "user.membership.read"
- "user.credential.write"
- "user.passkey.write"
- "user.feature.read"
- "user.feature.write"
- "user.feature.delete"
- "policy.read"
- "policy.write"
- "policy.delete"
@ -1165,6 +1193,9 @@ InternalAuthZ:
- "user.grant.write"
- "user.grant.delete"
- "user.membership.read"
- "user.feature.read"
- "user.feature.write"
- "user.feature.delete"
- "policy.read"
- "project.read"
- "project.role.read"
@ -1176,10 +1207,12 @@ InternalAuthZ:
- "org.idp.read"
- "org.action.read"
- "org.flow.read"
- "org.feature.read"
- "user.read"
- "user.global.read"
- "user.grant.read"
- "user.membership.read"
- "user.feature.read"
- "policy.read"
- "project.read"
- "project.member.read"
@ -1196,6 +1229,9 @@ InternalAuthZ:
- "org.idp.read"
- "org.idp.write"
- "org.idp.delete"
- "org.feature.read"
- "org.feature.write"
- "org.feature.delete"
- "policy.read"
- "policy.write"
- "policy.delete"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -694,6 +694,20 @@ module.exports = {
},
items: require("./docs/apis/resources/settings_service/sidebar.js"),
},
{
type: "category",
label: "Feature Lifecycle (Beta)",
link: {
type: "generated-index",
title: "Feature Service API (Beta)",
slug: "/apis/resources/feature_service",
description:
"This API is intended to manage features for ZITADEL. Feature settings that are available on multiple \"levels\", such as instance and organization. The higher level instance acts as a default for the lower level. When a feature is set on multiple levels, the lower level takes precedence. Features can be experimental where ZITADEL will assume a sane default, such as disabled. When over time confidence in such a feature grows, ZITADEL can default to enabling the feature. As a final step we might choose to always enable a feature and remove the setting from this API, reserving the proto field number. Such removal is not considered a breaking change. Setting a removed field will effectively result in a no-op.\n" +
"\n" +
"This project is in beta state. It can AND will continue breaking until a stable version is released.",
},
items: require("./docs/apis/resources/feature_service_v2/sidebar.js"),
},
]
},
{

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

View File

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

View File

@ -3,13 +3,17 @@ package admin
import (
"context"
"github.com/muhlemmer/gu"
object_pb "github.com/zitadel/zitadel/internal/api/grpc/object"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/command"
admin_pb "github.com/zitadel/zitadel/pkg/grpc/admin"
)
func (s *Server) ActivateFeatureLoginDefaultOrg(ctx context.Context, _ *admin_pb.ActivateFeatureLoginDefaultOrgRequest) (*admin_pb.ActivateFeatureLoginDefaultOrgResponse, error) {
details, err := s.command.SetBooleanInstanceFeature(ctx, domain.FeatureLoginDefaultOrg, true)
details, err := s.command.SetInstanceFeatures(ctx, &command.InstanceFeatures{
LoginDefaultOrg: gu.Ptr(true),
})
if err != nil {
return nil, err
}

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.CreationDate,
instance.ChangeDate,
instance.InstanceID(),
instance.ID,
),
Id: instance.InstanceID(),
Id: instance.ID,
Name: instance.Name,
Domains: DomainsToPb(instance.Domains),
Version: build.Version(),
@ -38,9 +38,9 @@ func InstanceDetailToPb(instance *query.Instance) *instance_pb.InstanceDetail {
instance.Sequence,
instance.CreationDate,
instance.ChangeDate,
instance.InstanceID(),
instance.ID,
),
Id: instance.InstanceID(),
Id: instance.ID,
Name: instance.Name,
Domains: DomainsToPb(instance.Domains),
Version: build.Version(),

View File

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

View File

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

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) {
instance, err := s.query.Instance(ctx, true)
instance, err := s.query.InstanceByID(ctx)
if err != nil {
return nil, err
}

View File

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

View File

@ -9,6 +9,7 @@ import (
"github.com/zitadel/oidc/v3/pkg/oidc"
"github.com/zitadel/oidc/v3/pkg/op"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/internal/user/model"
@ -95,7 +96,7 @@ func (s *Server) assertClientScopesForPAT(ctx context.Context, token *accessToke
if err != nil {
return zerrors.ThrowInternal(err, "OIDC-Cyc78", "Errors.Internal")
}
roles, err := s.query.SearchProjectRoles(ctx, s.features.TriggerIntrospectionProjections, &query.ProjectRoleSearchQueries{Queries: []query.SearchQuery{projectIDQuery}})
roles, err := s.query.SearchProjectRoles(ctx, authz.GetFeatures(ctx).TriggerIntrospectionProjections, &query.ProjectRoleSearchQueries{Queries: []query.SearchQuery{projectIDQuery}})
if err != nil {
return err
}

View File

@ -10,6 +10,7 @@ import (
"github.com/zitadel/oidc/v3/pkg/oidc"
"github.com/zitadel/oidc/v3/pkg/op"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/internal/telemetry/tracing"
@ -23,10 +24,11 @@ func (s *Server) Introspect(ctx context.Context, r *op.Request[op.IntrospectionR
span.EndWithError(err)
}()
if s.features.LegacyIntrospection {
features := authz.GetFeatures(ctx)
if features.LegacyIntrospection {
return s.LegacyServer.Introspect(ctx, r)
}
if s.features.TriggerIntrospectionProjections {
if features.TriggerIntrospectionProjections {
// Execute all triggers in one concurrent sweep.
query.TriggerIntrospectionProjections(ctx)
}

View File

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

View File

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

View File

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

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

View File

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

View File

@ -3,7 +3,6 @@ package eventsourcing
import (
"context"
"github.com/zitadel/zitadel/feature"
"github.com/zitadel/zitadel/internal/auth/repository/eventsourcing/eventstore"
auth_handler "github.com/zitadel/zitadel/internal/auth/repository/eventsourcing/handler"
auth_view "github.com/zitadel/zitadel/internal/auth/repository/eventsourcing/view"
@ -77,7 +76,6 @@ func Start(ctx context.Context, conf Config, systemDefaults sd.SystemDefaults, c
ProjectProvider: queryView,
ApplicationProvider: queries,
CustomTextProvider: queries,
FeatureCheck: feature.NewCheck(esV2),
IdGenerator: id.SonyFlakeGenerator(),
},
eventstore.TokenRepo{

View File

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

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

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
import "time"
import (
"time"
)
type ObjectDetails struct {
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
func NewTextArrayContainsCond(column string, value string) Condition {
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.ProcessedSequence = rm.Events[len(rm.Events)-1].Sequence()
// all events processed and not needed anymore
rm.Events = nil
rm.Events = []Event{}
rm.Events = rm.Events[0:0]
return nil
}

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

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

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"
"golang.org/x/text/language"
"github.com/zitadel/zitadel/internal/zerrors"
)
var (
instanceQuery = `SELECT projections.instances.id,` +
` projections.instances.creation_date,` +
` projections.instances.change_date,` +
` projections.instances.sequence,` +
` projections.instances.default_org_id,` +
` projections.instances.iam_project_id,` +
` projections.instances.console_client_id,` +
` projections.instances.console_app_id,` +
` projections.instances.default_language` +
` FROM projections.instances` +
` AS OF SYSTEM TIME '-1 ms'`
instanceCols = []string{
"id",
"creation_date",
"change_date",
"sequence",
"default_org_id",
"iam_project_id",
"console_client_id",
"console_app_id",
"default_language",
}
instancesQuery = `SELECT f.count, f.id,` +
` projections.instances.creation_date,` +
` projections.instances.change_date,` +
@ -93,76 +69,6 @@ func Test_InstancePrepares(t *testing.T) {
want want
object interface{}
}{
{
name: "prepareInstanceQuery no result",
additionalArgs: []reflect.Value{reflect.ValueOf("")},
prepare: prepareInstanceQuery,
want: want{
sqlExpectations: mockQueriesScanErr(
regexp.QuoteMeta(instanceQuery),
nil,
nil,
),
err: func(err error) (error, bool) {
if !zerrors.IsNotFound(err) {
return fmt.Errorf("err should be zitadel.NotFoundError got: %w", err), false
}
return nil, true
},
},
object: (*Instance)(nil),
},
{
name: "prepareInstanceQuery found",
additionalArgs: []reflect.Value{reflect.ValueOf("")},
prepare: prepareInstanceQuery,
want: want{
sqlExpectations: mockQuery(
regexp.QuoteMeta(instanceQuery),
instanceCols,
[]driver.Value{
"id",
testNow,
testNow,
uint64(20211108),
"global-org-id",
"project-id",
"client-id",
"app-id",
"en",
},
),
},
object: &Instance{
ID: "id",
CreationDate: testNow,
ChangeDate: testNow,
Sequence: 20211108,
DefaultOrgID: "global-org-id",
IAMProjectID: "project-id",
ConsoleID: "client-id",
ConsoleAppID: "app-id",
DefaultLang: language.English,
},
},
{
name: "prepareInstanceQuery sql err",
additionalArgs: []reflect.Value{reflect.ValueOf("")},
prepare: prepareInstanceQuery,
want: want{
sqlExpectations: mockQueryErr(
regexp.QuoteMeta(instanceQuery),
sql.ErrConnDone,
),
err: func(err error) (error, bool) {
if !errors.Is(err, sql.ErrConnDone) {
return fmt.Errorf("err should be sql.ErrConnDone got: %w", err), false
}
return nil, true
},
},
object: (*Instance)(nil),
},
{
name: "prepareInstancesQuery no result",
prepare: func(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Rows) (*Instances, error)) {

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
LimitsProjection *handler.Handler
RestrictionsProjection *handler.Handler
SystemFeatureProjection *handler.Handler
InstanceFeatureProjection *handler.Handler
)
type projection interface {
@ -148,6 +150,8 @@ func Create(ctx context.Context, sqlClient *database.DB, es handler.EventStore,
QuotaProjection = newQuotaProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["quotas"]))
LimitsProjection = newLimitsProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["limits"]))
RestrictionsProjection = newRestrictionsProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["restrictions"]))
SystemFeatureProjection = newSystemFeatureProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["system_features"]))
InstanceFeatureProjection = newInstanceFeatureProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["instance_features"]))
newProjectionsList()
return nil
}
@ -257,5 +261,7 @@ func newProjectionsList() {
QuotaProjection.handler,
LimitsProjection,
RestrictionsProjection,
SystemFeatureProjection,
InstanceFeatureProjection,
}
}

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"
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
import (
@ -6,14 +8,22 @@ import (
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/repository/feature/feature_v2"
)
var (
DefaultLoginInstanceEventType = EventTypeFromFeature(domain.FeatureLoginDefaultOrg)
DefaultLoginInstanceEventType = eventTypePrefix + eventstore.EventType(strings.ToLower("FeatureLoginDefaultOrg")) + setSuffix
)
func EventTypeFromFeature(feature domain.Feature) eventstore.EventType {
return eventTypePrefix + eventstore.EventType(strings.ToLower(feature.String())) + setSuffix
// DefaultLoginInstanceEventToV2 upgrades the SetEvent to a V2 SetEvent so that
// the v2 reducers can handle the V1 events.
func DefaultLoginInstanceEventToV2(e *SetEvent[Boolean]) *feature_v2.SetEvent[bool] {
v2e := &feature_v2.SetEvent[bool]{
BaseEvent: e.BaseEvent,
Value: e.Value.Boolean,
}
v2e.BaseEvent.EventType = feature_v2.InstanceLoginDefaultOrgEventType
return v2e
}
type SetEvent[T SetEventType] struct {

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