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