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

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