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
79 changed files with 4580 additions and 868 deletions

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