From a4763b1e4c625313c250cc6100b53d4715da2505 Mon Sep 17 00:00:00 2001 From: Livio Amstutz Date: Thu, 25 Mar 2021 17:26:21 +0100 Subject: [PATCH] feat: features (#1427) * features * features * features * fix json tags * add features handler to auth * mocks for tests * add setup step * fixes * add featurelist to auth api * grandfather state and typos * typo * merge new-eventstore * fix login policy tests * label policy in features * audit log retention --- cmd/zitadel/authz.yaml | 7 + cmd/zitadel/main.go | 22 +-- cmd/zitadel/setup.yaml | 3 + .../eventsourcing/eventstore/features.go | 70 ++++++++ .../eventsourcing/handler/features.go | 165 +++++++++++++++++ .../eventsourcing/handler/handler.go | 5 +- .../repository/eventsourcing/repository.go | 7 + .../repository/eventsourcing/view/features.go | 56 ++++++ internal/admin/repository/features.go | 12 ++ internal/admin/repository/repository.go | 1 + internal/api/authz/authorization.go | 11 ++ internal/api/authz/config.go | 1 + internal/api/authz/permissions_test.go | 4 + internal/api/authz/token.go | 1 + internal/api/grpc/admin/features.go | 88 +++++++++ internal/api/grpc/admin/server.go | 5 +- internal/api/grpc/auth/features.go | 18 ++ internal/api/grpc/auth/user.go | 6 +- internal/api/grpc/features/features.go | 64 +++++++ internal/api/grpc/management/features.go | 19 ++ internal/api/grpc/management/org.go | 6 +- internal/api/grpc/management/project.go | 6 +- .../grpc/management/project_application.go | 6 +- internal/api/grpc/management/server.go | 5 +- internal/api/grpc/management/user.go | 6 +- internal/api/grpc/policy/login_policy.go | 2 +- .../middleware/auth_interceptor_test.go | 3 + .../eventsourcing/eventstore/features.go | 66 +++++++ .../eventsourcing/eventstore/user.go | 10 +- .../eventsourcing/handler/features.go | 165 +++++++++++++++++ .../eventsourcing/handler/handler.go | 1 + .../repository/eventsourcing/repository.go | 5 + .../repository/eventsourcing/view/features.go | 56 ++++++ internal/auth/repository/features.go | 11 ++ internal/auth/repository/repository.go | 1 + internal/auth/repository/user.go | 3 +- .../eventstore/token_verifier.go | 115 ++++++++++-- .../eventsourcing/handler/features.go | 165 +++++++++++++++++ .../eventsourcing/handler/handler.go | 5 +- .../repository/eventsourcing/view/features.go | 56 ++++++ internal/command/command.go | 6 +- internal/command/features_model.go | 74 ++++++++ internal/command/iam_converter.go | 18 ++ internal/command/iam_features.go | 64 +++++++ internal/command/iam_features_model.go | 115 ++++++++++++ internal/command/iam_policy_login.go | 6 +- .../command/iam_policy_login_factors_model.go | 6 +- internal/command/main_test.go | 55 +++++- internal/command/org_features.go | 67 +++++++ internal/command/org_features_model.go | 121 +++++++++++++ internal/command/org_policy_login.go | 52 +++++- .../command/org_policy_login_factors_model.go | 6 +- internal/command/org_policy_login_test.go | 136 +++++++++++++- .../command/policy_login_factors_model.go | 4 +- internal/command/setup_step12.go | 52 ++++++ internal/command/setup_step9.go | 2 +- internal/domain/features.go | 54 ++++++ internal/domain/roles.go | 3 +- internal/domain/step.go | 1 + .../v1/internal/repository/sql/query.go | 2 + internal/eventstore/v1/models/field.go | 1 + internal/eventstore/v1/models/search_query.go | 19 +- .../eventstore/v1/models/search_query_old.go | 10 +- internal/features/model/features_view.go | 85 +++++++++ .../features/repository/view/features_view.go | 48 +++++ .../repository/view/model/features.go | 95 ++++++++++ .../repository/view/model/features_query.go | 61 +++++++ .../eventsourcing/eventstore/features.go | 70 ++++++++ .../eventsourcing/eventstore/org.go | 9 +- .../eventsourcing/eventstore/project.go | 17 +- .../eventsourcing/eventstore/user.go | 10 +- .../eventsourcing/handler/features.go | 165 +++++++++++++++++ .../eventsourcing/handler/handler.go | 5 +- .../repository/eventsourcing/repository.go | 8 +- .../repository/eventsourcing/view/features.go | 56 ++++++ internal/management/repository/features.go | 11 ++ internal/management/repository/org.go | 3 +- internal/management/repository/project.go | 6 +- internal/management/repository/repository.go | 1 + internal/management/repository/user.go | 3 +- internal/org/repository/view/query.go | 7 +- internal/project/repository/view/query.go | 7 +- .../authoption/options.proto | 1 + .../templates/auth_method_mapping.go.tmpl | 3 +- internal/repository/features/features.go | 169 ++++++++++++++++++ internal/repository/iam/eventstore.go | 3 +- internal/repository/iam/features.go | 43 +++++ internal/repository/org/eventstore.go | 4 +- internal/repository/org/features.go | 71 ++++++++ internal/setup/config.go | 2 + internal/user/repository/view/query.go | 7 +- migrations/cockroach/V1.35__features.sql | 103 +++++++++++ proto/zitadel/admin.proto | 138 ++++++++++++-- proto/zitadel/auth.proto | 16 ++ proto/zitadel/features.proto | 39 ++++ proto/zitadel/management.proto | 43 ++++- proto/zitadel/options.proto | 3 +- 97 files changed, 3335 insertions(+), 109 deletions(-) create mode 100644 internal/admin/repository/eventsourcing/eventstore/features.go create mode 100644 internal/admin/repository/eventsourcing/handler/features.go create mode 100644 internal/admin/repository/eventsourcing/view/features.go create mode 100644 internal/admin/repository/features.go create mode 100644 internal/api/grpc/admin/features.go create mode 100644 internal/api/grpc/auth/features.go create mode 100644 internal/api/grpc/features/features.go create mode 100644 internal/api/grpc/management/features.go create mode 100644 internal/auth/repository/eventsourcing/eventstore/features.go create mode 100644 internal/auth/repository/eventsourcing/handler/features.go create mode 100644 internal/auth/repository/eventsourcing/view/features.go create mode 100644 internal/auth/repository/features.go create mode 100644 internal/authz/repository/eventsourcing/handler/features.go create mode 100644 internal/authz/repository/eventsourcing/view/features.go create mode 100644 internal/command/features_model.go create mode 100644 internal/command/iam_features.go create mode 100644 internal/command/iam_features_model.go create mode 100644 internal/command/org_features.go create mode 100644 internal/command/org_features_model.go create mode 100644 internal/command/setup_step12.go create mode 100644 internal/domain/features.go create mode 100644 internal/features/model/features_view.go create mode 100644 internal/features/repository/view/features_view.go create mode 100644 internal/features/repository/view/model/features.go create mode 100644 internal/features/repository/view/model/features_query.go create mode 100644 internal/management/repository/eventsourcing/eventstore/features.go create mode 100644 internal/management/repository/eventsourcing/handler/features.go create mode 100644 internal/management/repository/eventsourcing/view/features.go create mode 100644 internal/management/repository/features.go create mode 100644 internal/repository/features/features.go create mode 100644 internal/repository/iam/features.go create mode 100644 internal/repository/org/features.go create mode 100644 migrations/cockroach/V1.35__features.sql create mode 100644 proto/zitadel/features.proto diff --git a/cmd/zitadel/authz.yaml b/cmd/zitadel/authz.yaml index f53b4a2d4b..d1f5227d5d 100644 --- a/cmd/zitadel/authz.yaml +++ b/cmd/zitadel/authz.yaml @@ -4,6 +4,8 @@ InternalAuthZ: Permissions: - "iam.read" - "iam.write" + - "iam.features.read" + - "iam.features.write" - "iam.policy.read" - "iam.policy.write" - "iam.policy.delete" @@ -31,6 +33,7 @@ InternalAuthZ: - "user.grant.write" - "user.grant.delete" - "user.membership.read" + - "features.read" - "policy.read" - "policy.write" - "policy.delete" @@ -56,6 +59,7 @@ InternalAuthZ: - Role: 'IAM_OWNER_VIEWER' Permissions: - "iam.read" + - "iam.features.read" - "iam.policy.read" - "iam.member.read" - "iam.idp.read" @@ -66,6 +70,7 @@ InternalAuthZ: - "user.global.read" - "user.grant.read" - "user.membership.read" + - "features.read" - "policy.read" - "project.read" - "project.member.read" @@ -93,6 +98,7 @@ InternalAuthZ: - "user.grant.write" - "user.grant.delete" - "user.membership.read" + - "features.read" - "policy.read" - "policy.write" - "policy.delete" @@ -123,6 +129,7 @@ InternalAuthZ: - "user.global.read" - "user.grant.read" - "user.membership.read" + - "features.read" - "policy.read" - "project.read" - "project.member.read" diff --git a/cmd/zitadel/main.go b/cmd/zitadel/main.go index 9b88f35289..2b27e31e2b 100644 --- a/cmd/zitadel/main.go +++ b/cmd/zitadel/main.go @@ -103,24 +103,24 @@ func startZitadel(configPaths []string) { logging.Log("MAIN-FaF2r").OnError(err).Fatal("cannot read config") ctx := context.Background() - esCommands, err := eventstore.StartWithUser(conf.EventstoreBase, conf.Commands.Eventstore) - if err != nil { - return - } - commands, err := command.StartCommands(esCommands, conf.SystemDefaults, conf.InternalAuthZ) - if err != nil { - return - } esQueries, err := eventstore.StartWithUser(conf.EventstoreBase, conf.Queries.Eventstore) if err != nil { - return + logging.Log("MAIN-Ddv21").OnError(err).Fatal("cannot start eventstore for queries") } queries, err := query.StartQueries(esQueries, conf.SystemDefaults) if err != nil { - return + logging.Log("MAIN-Ddv21").OnError(err).Fatal("cannot start queries") } authZRepo, err := authz.Start(ctx, conf.AuthZ, conf.InternalAuthZ, conf.SystemDefaults, queries) logging.Log("MAIN-s9KOw").OnError(err).Fatal("error starting authz repo") + esCommands, err := eventstore.StartWithUser(conf.EventstoreBase, conf.Commands.Eventstore) + if err != nil { + logging.Log("MAIN-Ddv21").OnError(err).Fatal("cannot start eventstore for commands") + } + commands, err := command.StartCommands(esCommands, conf.SystemDefaults, conf.InternalAuthZ, authZRepo) + if err != nil { + logging.Log("MAIN-Ddv21").OnError(err).Fatal("cannot start commands") + } var authRepo *auth_es.EsRepository if *authEnabled || *oidcEnabled || *loginEnabled { authRepo, err = auth_es.Start(conf.Auth, conf.InternalAuthZ, conf.SystemDefaults, commands, queries, authZRepo, esQueries) @@ -190,7 +190,7 @@ func startSetup(configPaths []string, localDevMode bool) { es, err := eventstore.Start(conf.Eventstore) logging.Log("MAIN-Ddt3").OnError(err).Fatal("cannot start eventstore") - commands, err := command.StartCommands(es, conf.SystemDefaults, conf.InternalAuthZ) + commands, err := command.StartCommands(es, conf.SystemDefaults, conf.InternalAuthZ, nil) logging.Log("MAIN-dsjrr").OnError(err).Fatal("cannot start command side") err = setup.Execute(ctx, conf.SetUp, conf.SystemDefaults.IamID, commands) diff --git a/cmd/zitadel/setup.yaml b/cmd/zitadel/setup.yaml index a0e4b08a34..e50d4e3306 100644 --- a/cmd/zitadel/setup.yaml +++ b/cmd/zitadel/setup.yaml @@ -175,3 +175,6 @@ SetUp: ButtonText: Login Step11: MigrateV1EventstoreToV2: $ZITADEL_MIGRATE_ES_V1 + Step12: + TierName: FREE Tier + AuditLogRetention: 9600h #400d = ~13months diff --git a/internal/admin/repository/eventsourcing/eventstore/features.go b/internal/admin/repository/eventsourcing/eventstore/features.go new file mode 100644 index 0000000000..050b2e07ad --- /dev/null +++ b/internal/admin/repository/eventsourcing/eventstore/features.go @@ -0,0 +1,70 @@ +package eventstore + +import ( + "context" + + "github.com/caos/logging" + + admin_view "github.com/caos/zitadel/internal/admin/repository/eventsourcing/view" + "github.com/caos/zitadel/internal/config/systemdefaults" + "github.com/caos/zitadel/internal/domain" + "github.com/caos/zitadel/internal/errors" + v1 "github.com/caos/zitadel/internal/eventstore/v1" + "github.com/caos/zitadel/internal/eventstore/v1/models" + features_model "github.com/caos/zitadel/internal/features/model" + "github.com/caos/zitadel/internal/features/repository/view/model" + iam_view "github.com/caos/zitadel/internal/iam/repository/view" +) + +type FeaturesRepo struct { + Eventstore v1.Eventstore + + View *admin_view.View + + SearchLimit uint64 + SystemDefaults systemdefaults.SystemDefaults +} + +func (repo *FeaturesRepo) GetDefaultFeatures(ctx context.Context) (*features_model.FeaturesView, error) { + features, viewErr := repo.View.FeaturesByAggregateID(domain.IAMID) + if viewErr != nil && !errors.IsNotFound(viewErr) { + return nil, viewErr + } + if errors.IsNotFound(viewErr) { + features = new(model.FeaturesView) + } + events, esErr := repo.getIAMEvents(ctx, features.Sequence) + if errors.IsNotFound(viewErr) && len(events) == 0 { + return nil, errors.ThrowNotFound(nil, "EVENT-Lsoj7", "Errors.Org.NotFound") + } + if esErr != nil { + logging.Log("EVENT-PSoc3").WithError(esErr).Debug("error retrieving new events") + return model.FeaturesToModel(features), nil + } + featuresCopy := *features + for _, event := range events { + if err := featuresCopy.AppendEvent(event); err != nil { + return model.FeaturesToModel(&featuresCopy), nil + } + } + return model.FeaturesToModel(&featuresCopy), nil +} + +func (repo *FeaturesRepo) GetOrgFeatures(ctx context.Context, orgID string) (*features_model.FeaturesView, error) { + features, err := repo.View.FeaturesByAggregateID(orgID) + if errors.IsNotFound(err) { + return repo.GetDefaultFeatures(ctx) + } + if err != nil { + return nil, err + } + return model.FeaturesToModel(features), nil +} + +func (repo *FeaturesRepo) getIAMEvents(ctx context.Context, sequence uint64) ([]*models.Event, error) { + query, err := iam_view.IAMByIDQuery(domain.IAMID, sequence) + if err != nil { + return nil, err + } + return repo.Eventstore.FilterEvents(ctx, query) +} diff --git a/internal/admin/repository/eventsourcing/handler/features.go b/internal/admin/repository/eventsourcing/handler/features.go new file mode 100644 index 0000000000..da1671ddb7 --- /dev/null +++ b/internal/admin/repository/eventsourcing/handler/features.go @@ -0,0 +1,165 @@ +package handler + +import ( + "context" + + "github.com/caos/logging" + + "github.com/caos/zitadel/internal/domain" + caos_errs "github.com/caos/zitadel/internal/errors" + "github.com/caos/zitadel/internal/eventstore/v1" + es_models "github.com/caos/zitadel/internal/eventstore/v1/models" + "github.com/caos/zitadel/internal/eventstore/v1/query" + "github.com/caos/zitadel/internal/eventstore/v1/spooler" + "github.com/caos/zitadel/internal/features/repository/view/model" + "github.com/caos/zitadel/internal/iam/repository/eventsourcing" + iam_es_model "github.com/caos/zitadel/internal/iam/repository/eventsourcing/model" + org_es_model "github.com/caos/zitadel/internal/org/repository/eventsourcing/model" + iam_repo "github.com/caos/zitadel/internal/repository/iam" + org_repo "github.com/caos/zitadel/internal/repository/org" +) + +const ( + featuresTable = "adminapi.features" +) + +type Features struct { + handler + subscription *v1.Subscription +} + +func newFeatures(handler handler) *Features { + h := &Features{ + handler: handler, + } + + h.subscribe() + + return h +} + +func (p *Features) subscribe() { + p.subscription = p.es.Subscribe(p.AggregateTypes()...) + go func() { + for event := range p.subscription.Events { + query.ReduceEvent(p, event) + } + }() +} + +func (p *Features) ViewModel() string { + return featuresTable +} + +func (p *Features) AggregateTypes() []es_models.AggregateType { + return []es_models.AggregateType{iam_es_model.IAMAggregate, org_es_model.OrgAggregate} +} + +func (p *Features) EventQuery() (*es_models.SearchQuery, error) { + sequence, err := p.view.GetLatestFeaturesSequence() + if err != nil { + return nil, err + } + return es_models.NewSearchQuery(). + AggregateTypeFilter(p.AggregateTypes()...). + LatestSequenceFilter(sequence.CurrentSequence), nil +} + +func (p *Features) CurrentSequence() (uint64, error) { + sequence, err := p.view.GetLatestFeaturesSequence() + if err != nil { + return 0, err + } + return sequence.CurrentSequence, nil +} + +func (p *Features) Reduce(event *es_models.Event) (err error) { + switch event.AggregateType { + case org_es_model.OrgAggregate, iam_es_model.IAMAggregate: + err = p.processFeatures(event) + } + return err +} + +func (p *Features) processFeatures(event *es_models.Event) (err error) { + features := new(model.FeaturesView) + switch string(event.Type) { + case string(org_es_model.OrgAdded): + features, err = p.getDefaultFeatures() + if err != nil { + return err + } + features.AggregateID = event.AggregateID + features.Default = true + case string(iam_repo.FeaturesSetEventType): + defaultFeatures, err := p.view.AllDefaultFeatures() + if err != nil { + return err + } + for _, features := range defaultFeatures { + err = features.AppendEvent(event) + if err != nil { + return err + } + } + return p.view.PutFeaturesList(defaultFeatures, event) + case string(org_repo.FeaturesSetEventType): + features, err = p.view.FeaturesByAggregateID(event.AggregateID) + if err != nil { + return err + } + err = features.AppendEvent(event) + case string(org_repo.FeaturesRemovedEventType): + features, err = p.getDefaultFeatures() + if err != nil { + return err + } + features.AggregateID = event.AggregateID + features.Default = true + default: + return p.view.ProcessedFeaturesSequence(event) + } + if err != nil { + return err + } + return p.view.PutFeatures(features, event) +} + +func (p *Features) OnError(event *es_models.Event, err error) error { + logging.LogWithFields("SPOOL-Wj8sf", "id", event.AggregateID).WithError(err).Warn("something went wrong in login features handler") + return spooler.HandleError(event, err, p.view.GetLatestFeaturesFailedEvent, p.view.ProcessedFeaturesFailedEvent, p.view.ProcessedFeaturesSequence, p.errorCountUntilSkip) +} + +func (p *Features) OnSuccess() error { + return spooler.HandleSuccess(p.view.UpdateFeaturesSpoolerRunTimestamp) +} + +func (p *Features) getDefaultFeatures() (*model.FeaturesView, error) { + features, featuresErr := p.view.FeaturesByAggregateID(domain.IAMID) + if featuresErr != nil && !caos_errs.IsNotFound(featuresErr) { + return nil, featuresErr + } + if features == nil { + features = &model.FeaturesView{} + } + events, err := p.getIAMEvents(features.Sequence) + if err != nil { + return features, featuresErr + } + featuresCopy := *features + for _, event := range events { + if err := featuresCopy.AppendEvent(event); err != nil { + return features, nil + } + } + return &featuresCopy, nil +} + +func (p *Features) getIAMEvents(sequence uint64) ([]*es_models.Event, error) { + query, err := eventsourcing.IAMByIDQuery(domain.IAMID, sequence) + if err != nil { + return nil, err + } + + return p.es.FilterEvents(context.Background(), query) +} diff --git a/internal/admin/repository/eventsourcing/handler/handler.go b/internal/admin/repository/eventsourcing/handler/handler.go index 2d61c7be06..0b92718041 100644 --- a/internal/admin/repository/eventsourcing/handler/handler.go +++ b/internal/admin/repository/eventsourcing/handler/handler.go @@ -1,9 +1,10 @@ package handler import ( - "github.com/caos/zitadel/internal/eventstore/v1" "time" + "github.com/caos/zitadel/internal/eventstore/v1" + "github.com/caos/zitadel/internal/admin/repository/eventsourcing/view" "github.com/caos/zitadel/internal/config/systemdefaults" "github.com/caos/zitadel/internal/config/types" @@ -62,6 +63,8 @@ func Register(configs Configs, bulkLimit, errorCount uint64, view *view.View, es handler{view, bulkLimit, configs.cycleDuration("MailTemplate"), errorCount, es}), newMailText( handler{view, bulkLimit, configs.cycleDuration("MailText"), errorCount, es}), + newFeatures( + handler{view, bulkLimit, configs.cycleDuration("Features"), errorCount, es}), } } diff --git a/internal/admin/repository/eventsourcing/repository.go b/internal/admin/repository/eventsourcing/repository.go index aab8199c20..98374d71d2 100644 --- a/internal/admin/repository/eventsourcing/repository.go +++ b/internal/admin/repository/eventsourcing/repository.go @@ -24,6 +24,7 @@ type EsRepository struct { eventstore.OrgRepo eventstore.IAMRepository eventstore.AdministratorRepo + eventstore.FeaturesRepo } func Start(ctx context.Context, conf Config, systemDefaults sd.SystemDefaults, roles []string) (*EsRepository, error) { @@ -60,6 +61,12 @@ func Start(ctx context.Context, conf Config, systemDefaults sd.SystemDefaults, r AdministratorRepo: eventstore.AdministratorRepo{ View: view, }, + FeaturesRepo: eventstore.FeaturesRepo{ + Eventstore: es, + View: view, + SearchLimit: conf.SearchLimit, + SystemDefaults: systemDefaults, + }, }, nil } diff --git a/internal/admin/repository/eventsourcing/view/features.go b/internal/admin/repository/eventsourcing/view/features.go new file mode 100644 index 0000000000..7ea2629da6 --- /dev/null +++ b/internal/admin/repository/eventsourcing/view/features.go @@ -0,0 +1,56 @@ +package view + +import ( + "github.com/caos/zitadel/internal/eventstore/v1/models" + "github.com/caos/zitadel/internal/features/repository/view" + "github.com/caos/zitadel/internal/features/repository/view/model" + global_view "github.com/caos/zitadel/internal/view/repository" +) + +const ( + featuresTable = "adminapi.features" +) + +func (v *View) AllDefaultFeatures() ([]*model.FeaturesView, error) { + return view.GetDefaultFeatures(v.Db, featuresTable) +} + +func (v *View) FeaturesByAggregateID(aggregateID string) (*model.FeaturesView, error) { + return view.GetFeaturesByAggregateID(v.Db, featuresTable, aggregateID) +} + +func (v *View) PutFeatures(features *model.FeaturesView, event *models.Event) error { + err := view.PutFeatures(v.Db, featuresTable, features) + if err != nil { + return err + } + return v.ProcessedFeaturesSequence(event) +} + +func (v *View) PutFeaturesList(features []*model.FeaturesView, event *models.Event) error { + err := view.PutFeaturesList(v.Db, featuresTable, features...) + if err != nil { + return err + } + return v.ProcessedFeaturesSequence(event) +} + +func (v *View) GetLatestFeaturesSequence() (*global_view.CurrentSequence, error) { + return v.latestSequence(featuresTable) +} + +func (v *View) ProcessedFeaturesSequence(event *models.Event) error { + return v.saveCurrentSequence(featuresTable, event) +} + +func (v *View) UpdateFeaturesSpoolerRunTimestamp() error { + return v.updateSpoolerRunSequence(featuresTable) +} + +func (v *View) GetLatestFeaturesFailedEvent(sequence uint64) (*global_view.FailedEvent, error) { + return v.latestFailedEvent(featuresTable, sequence) +} + +func (v *View) ProcessedFeaturesFailedEvent(failedEvent *global_view.FailedEvent) error { + return v.saveFailedEvent(failedEvent) +} diff --git a/internal/admin/repository/features.go b/internal/admin/repository/features.go new file mode 100644 index 0000000000..c7ecda8782 --- /dev/null +++ b/internal/admin/repository/features.go @@ -0,0 +1,12 @@ +package repository + +import ( + "context" + + features_model "github.com/caos/zitadel/internal/features/model" +) + +type FeaturesRepository interface { + GetDefaultFeatures(ctx context.Context) (*features_model.FeaturesView, error) + GetOrgFeatures(ctx context.Context, id string) (*features_model.FeaturesView, error) +} diff --git a/internal/admin/repository/repository.go b/internal/admin/repository/repository.go index b76321a179..ffeeae4df0 100644 --- a/internal/admin/repository/repository.go +++ b/internal/admin/repository/repository.go @@ -7,4 +7,5 @@ type Repository interface { OrgRepository IAMRepository AdministratorRepository + FeaturesRepository } diff --git a/internal/api/authz/authorization.go b/internal/api/authz/authorization.go index 36b17e5cd8..58f7225fa0 100644 --- a/internal/api/authz/authorization.go +++ b/internal/api/authz/authorization.go @@ -23,6 +23,13 @@ func CheckUserAuthorization(ctx context.Context, req interface{}, token, orgID s return nil, err } + if requiredAuthOption.Feature != "" { + err = CheckOrgFeatures(ctx, verifier, ctxData.OrgID, requiredAuthOption.Feature) + if err != nil { + return nil, err + } + } + if requiredAuthOption.Permission == authenticated { return func(parent context.Context) context.Context { return context.WithValue(parent, dataKey, ctxData) @@ -49,6 +56,10 @@ func CheckUserAuthorization(ctx context.Context, req interface{}, token, orgID s }, nil } +func CheckOrgFeatures(ctx context.Context, t *TokenVerifier, orgID string, requiredFeatures ...string) error { + return t.authZRepo.CheckOrgFeatures(ctx, orgID, requiredFeatures...) +} + func checkUserPermissions(req interface{}, userPerms []string, authOpt Option) error { if len(userPerms) == 0 { return errors.ThrowPermissionDenied(nil, "AUTH-5mWD2", "No matching permissions found") diff --git a/internal/api/authz/config.go b/internal/api/authz/config.go index 4e32416a21..d9b79ee19c 100644 --- a/internal/api/authz/config.go +++ b/internal/api/authz/config.go @@ -14,6 +14,7 @@ type MethodMapping map[string]Option type Option struct { Permission string CheckParam string + Feature string } func (a *Config) getPermissionsFromRole(role string) []string { diff --git a/internal/api/authz/permissions_test.go b/internal/api/authz/permissions_test.go index 26bdd9805b..a7f6360807 100644 --- a/internal/api/authz/permissions_test.go +++ b/internal/api/authz/permissions_test.go @@ -34,6 +34,10 @@ func (v *testVerifier) VerifierClientID(ctx context.Context, appName string) (st return "clientID", nil } +func (v *testVerifier) CheckOrgFeatures(context.Context, string, ...string) error { + return nil +} + func equalStringArray(a, b []string) bool { if len(a) != len(b) { return false diff --git a/internal/api/authz/token.go b/internal/api/authz/token.go index aacba6bba7..bca8f5db4f 100644 --- a/internal/api/authz/token.go +++ b/internal/api/authz/token.go @@ -25,6 +25,7 @@ type authZRepo interface { SearchMyMemberships(ctx context.Context) ([]*Membership, error) ProjectIDAndOriginsByClientID(ctx context.Context, clientID string) (projectID string, origins []string, err error) ExistsOrg(ctx context.Context, orgID string) error + CheckOrgFeatures(ctx context.Context, orgID string, requiredFeatures ...string) error } func Start(authZRepo authZRepo) (v *TokenVerifier) { diff --git a/internal/api/grpc/admin/features.go b/internal/api/grpc/admin/features.go new file mode 100644 index 0000000000..159a2b4119 --- /dev/null +++ b/internal/api/grpc/admin/features.go @@ -0,0 +1,88 @@ +package admin + +import ( + "context" + + features_grpc "github.com/caos/zitadel/internal/api/grpc/features" + object_grpc "github.com/caos/zitadel/internal/api/grpc/object" + "github.com/caos/zitadel/internal/domain" + admin_pb "github.com/caos/zitadel/pkg/grpc/admin" +) + +func (s *Server) GetDefaultFeatures(ctx context.Context, _ *admin_pb.GetDefaultFeaturesRequest) (*admin_pb.GetDefaultFeaturesResponse, error) { + features, err := s.features.GetDefaultFeatures(ctx) + if err != nil { + return nil, err + } + return &admin_pb.GetDefaultFeaturesResponse{ + Features: features_grpc.FeaturesFromModel(features), + }, nil +} + +func (s *Server) SetDefaultFeatures(ctx context.Context, req *admin_pb.SetDefaultFeaturesRequest) (*admin_pb.SetDefaultFeaturesResponse, error) { + details, err := s.command.SetDefaultFeatures(ctx, setDefaultFeaturesRequestToDomain(req)) + if err != nil { + return nil, err + } + return &admin_pb.SetDefaultFeaturesResponse{ + Details: object_grpc.DomainToChangeDetailsPb(details), + }, nil +} + +func (s *Server) GetOrgFeatures(ctx context.Context, req *admin_pb.GetOrgFeaturesRequest) (*admin_pb.GetOrgFeaturesResponse, error) { + features, err := s.features.GetOrgFeatures(ctx, req.OrgId) + if err != nil { + return nil, err + } + return &admin_pb.GetOrgFeaturesResponse{ + Features: features_grpc.FeaturesFromModel(features), + }, nil +} + +func (s *Server) SetOrgFeatures(ctx context.Context, req *admin_pb.SetOrgFeaturesRequest) (*admin_pb.SetOrgFeaturesResponse, error) { + details, err := s.command.SetOrgFeatures(ctx, req.OrgId, setOrgFeaturesRequestToDomain(req)) + if err != nil { + return nil, err + } + return &admin_pb.SetOrgFeaturesResponse{ + Details: object_grpc.DomainToChangeDetailsPb(details), + }, nil +} + +func (s *Server) ResetOrgFeatures(ctx context.Context, req *admin_pb.ResetOrgFeaturesRequest) (*admin_pb.ResetOrgFeaturesResponse, error) { + details, err := s.command.RemoveOrgFeatures(ctx, req.OrgId) + if err != nil { + return nil, err + } + return &admin_pb.ResetOrgFeaturesResponse{ + Details: object_grpc.DomainToChangeDetailsPb(details), + }, nil +} + +func setDefaultFeaturesRequestToDomain(req *admin_pb.SetDefaultFeaturesRequest) *domain.Features { + return &domain.Features{ + TierName: req.TierName, + TierDescription: req.Description, + AuditLogRetention: req.AuditLogRetention.AsDuration(), + LoginPolicyFactors: req.LoginPolicyFactors, + LoginPolicyIDP: req.LoginPolicyIdp, + LoginPolicyPasswordless: req.LoginPolicyPasswordless, + LoginPolicyRegistration: req.LoginPolicyRegistration, + LoginPolicyUsernameLogin: req.LoginPolicyUsernameLogin, + } +} + +func setOrgFeaturesRequestToDomain(req *admin_pb.SetOrgFeaturesRequest) *domain.Features { + return &domain.Features{ + TierName: req.TierName, + TierDescription: req.Description, + State: features_grpc.FeaturesStateToDomain(req.State), + StateDescription: req.StateDescription, + AuditLogRetention: req.AuditLogRetention.AsDuration(), + LoginPolicyFactors: req.LoginPolicyFactors, + LoginPolicyIDP: req.LoginPolicyIdp, + LoginPolicyPasswordless: req.LoginPolicyPasswordless, + LoginPolicyRegistration: req.LoginPolicyRegistration, + LoginPolicyUsernameLogin: req.LoginPolicyUsernameLogin, + } +} diff --git a/internal/api/grpc/admin/server.go b/internal/api/grpc/admin/server.go index 2df0d1ff6b..df53116650 100644 --- a/internal/api/grpc/admin/server.go +++ b/internal/api/grpc/admin/server.go @@ -1,6 +1,8 @@ package admin import ( + "google.golang.org/grpc" + "github.com/caos/zitadel/internal/admin/repository" "github.com/caos/zitadel/internal/admin/repository/eventsourcing" "github.com/caos/zitadel/internal/api/authz" @@ -8,7 +10,6 @@ import ( "github.com/caos/zitadel/internal/command" "github.com/caos/zitadel/internal/query" "github.com/caos/zitadel/pkg/grpc/admin" - "google.golang.org/grpc" ) const ( @@ -25,6 +26,7 @@ type Server struct { iam repository.IAMRepository administrator repository.AdministratorRepository repo repository.Repository + features repository.FeaturesRepository } type Config struct { @@ -39,6 +41,7 @@ func CreateServer(command *command.Commands, query *query.Queries, repo reposito iam: repo, administrator: repo, repo: repo, + features: repo, } } diff --git a/internal/api/grpc/auth/features.go b/internal/api/grpc/auth/features.go new file mode 100644 index 0000000000..4fcf6d24d1 --- /dev/null +++ b/internal/api/grpc/auth/features.go @@ -0,0 +1,18 @@ +package auth + +import ( + "context" + + "github.com/caos/zitadel/internal/api/authz" + auth_pb "github.com/caos/zitadel/pkg/grpc/auth" +) + +func (s *Server) ListMyZitadelFeatures(ctx context.Context, _ *auth_pb.ListMyZitadelFeaturesRequest) (*auth_pb.ListMyZitadelFeaturesResponse, error) { + features, err := s.repo.GetOrgFeatures(ctx, authz.GetCtxData(ctx).OrgID) + if err != nil { + return nil, err + } + return &auth_pb.ListMyZitadelFeaturesResponse{ + Result: features.FeatureList(), + }, nil +} diff --git a/internal/api/grpc/auth/user.go b/internal/api/grpc/auth/user.go index 1c44de8730..27f68afbf9 100644 --- a/internal/api/grpc/auth/user.go +++ b/internal/api/grpc/auth/user.go @@ -24,7 +24,11 @@ func (s *Server) GetMyUser(ctx context.Context, _ *auth_pb.GetMyUserRequest) (*a func (s *Server) ListMyUserChanges(ctx context.Context, req *auth_pb.ListMyUserChangesRequest) (*auth_pb.ListMyUserChangesResponse, error) { offset, limit, asc := object.ListQueryToModel(req.Query) - changes, err := s.repo.MyUserChanges(ctx, offset, limit, asc) + features, err := s.repo.GetOrgFeatures(ctx, authz.GetCtxData(ctx).ResourceOwner) + if err != nil { + return nil, err + } + changes, err := s.repo.MyUserChanges(ctx, offset, limit, asc, features.AuditLogRetention) if err != nil { return nil, err } diff --git a/internal/api/grpc/features/features.go b/internal/api/grpc/features/features.go new file mode 100644 index 0000000000..c60f4e5ed6 --- /dev/null +++ b/internal/api/grpc/features/features.go @@ -0,0 +1,64 @@ +package features + +import ( + "google.golang.org/protobuf/types/known/durationpb" + + object_grpc "github.com/caos/zitadel/internal/api/grpc/object" + "github.com/caos/zitadel/internal/domain" + features_model "github.com/caos/zitadel/internal/features/model" + features_pb "github.com/caos/zitadel/pkg/grpc/features" +) + +func FeaturesFromModel(features *features_model.FeaturesView) *features_pb.Features { + return &features_pb.Features{ + Details: object_grpc.ToViewDetailsPb(features.Sequence, features.CreationDate, features.ChangeDate, features.AggregateID), + Tier: FeatureTierToPb(features.TierName, features.TierDescription, features.State, features.StateDescription), + IsDefault: features.Default, + + AuditLogRetention: durationpb.New(features.AuditLogRetention), + LoginPolicyFactors: features.LoginPolicyFactors, + LoginPolicyIdp: features.LoginPolicyIDP, + LoginPolicyPasswordless: features.LoginPolicyPasswordless, + LoginPolicyRegistration: features.LoginPolicyRegistration, + LoginPolicyUsernameLogin: features.LoginPolicyUsernameLogin, + } +} + +func FeatureTierToPb(name, description string, status domain.FeaturesState, statusDescription string) *features_pb.FeatureTier { + return &features_pb.FeatureTier{ + Name: name, + Description: description, + State: FeaturesStateToPb(status), + StatusInfo: statusDescription, + } +} + +func FeaturesStateToPb(status domain.FeaturesState) features_pb.FeaturesState { + switch status { + case domain.FeaturesStateActive: + return features_pb.FeaturesState_FEATURES_STATE_ACTIVE + case domain.FeaturesStateActionRequired: + return features_pb.FeaturesState_FEATURES_STATE_ACTION_REQUIRED + case domain.FeaturesStateCanceled: + return features_pb.FeaturesState_FEATURES_STATE_CANCELED + case domain.FeaturesStateGrandfathered: + return features_pb.FeaturesState_FEATURES_STATE_GRANDFATHERED + default: + return features_pb.FeaturesState_FEATURES_STATE_ACTIVE + } +} + +func FeaturesStateToDomain(status features_pb.FeaturesState) domain.FeaturesState { + switch status { + case features_pb.FeaturesState_FEATURES_STATE_ACTIVE: + return domain.FeaturesStateActive + case features_pb.FeaturesState_FEATURES_STATE_ACTION_REQUIRED: + return domain.FeaturesStateActionRequired + case features_pb.FeaturesState_FEATURES_STATE_CANCELED: + return domain.FeaturesStateCanceled + case features_pb.FeaturesState_FEATURES_STATE_GRANDFATHERED: + return domain.FeaturesStateGrandfathered + default: + return -1 + } +} diff --git a/internal/api/grpc/management/features.go b/internal/api/grpc/management/features.go new file mode 100644 index 0000000000..9a7551b4b1 --- /dev/null +++ b/internal/api/grpc/management/features.go @@ -0,0 +1,19 @@ +package management + +import ( + "context" + + "github.com/caos/zitadel/internal/api/authz" + features_grpc "github.com/caos/zitadel/internal/api/grpc/features" + mgmt_pb "github.com/caos/zitadel/pkg/grpc/management" +) + +func (s *Server) GetFeatures(ctx context.Context, req *mgmt_pb.GetFeaturesRequest) (*mgmt_pb.GetFeaturesResponse, error) { + features, err := s.features.GetOrgFeatures(ctx, authz.GetCtxData(ctx).OrgID) + if err != nil { + return nil, err + } + return &mgmt_pb.GetFeaturesResponse{ + Features: features_grpc.FeaturesFromModel(features), + }, nil +} diff --git a/internal/api/grpc/management/org.go b/internal/api/grpc/management/org.go index 2535a5444a..bac03b75ab 100644 --- a/internal/api/grpc/management/org.go +++ b/internal/api/grpc/management/org.go @@ -33,7 +33,11 @@ func (s *Server) GetOrgByDomainGlobal(ctx context.Context, req *mgmt_pb.GetOrgBy func (s *Server) ListOrgChanges(ctx context.Context, req *mgmt_pb.ListOrgChangesRequest) (*mgmt_pb.ListOrgChangesResponse, error) { offset, limit, asc := object.ListQueryToModel(req.Query) - response, err := s.org.OrgChanges(ctx, authz.GetCtxData(ctx).OrgID, offset, limit, asc) + features, err := s.features.GetOrgFeatures(ctx, authz.GetCtxData(ctx).OrgID) + if err != nil { + return nil, err + } + response, err := s.org.OrgChanges(ctx, authz.GetCtxData(ctx).OrgID, offset, limit, asc, features.AuditLogRetention) if err != nil { return nil, err } diff --git a/internal/api/grpc/management/project.go b/internal/api/grpc/management/project.go index 01217ef73d..aaf33bca07 100644 --- a/internal/api/grpc/management/project.go +++ b/internal/api/grpc/management/project.go @@ -73,7 +73,11 @@ func (s *Server) ListGrantedProjects(ctx context.Context, req *mgmt_pb.ListGrant func (s *Server) ListProjectChanges(ctx context.Context, req *mgmt_pb.ListProjectChangesRequest) (*mgmt_pb.ListProjectChangesResponse, error) { offset, limit, asc := object_grpc.ListQueryToModel(req.Query) - res, err := s.project.ProjectChanges(ctx, req.ProjectId, offset, limit, asc) + features, err := s.features.GetOrgFeatures(ctx, authz.GetCtxData(ctx).OrgID) + if err != nil { + return nil, err + } + res, err := s.project.ProjectChanges(ctx, req.ProjectId, offset, limit, asc, features.AuditLogRetention) if err != nil { return nil, err } diff --git a/internal/api/grpc/management/project_application.go b/internal/api/grpc/management/project_application.go index 05586d4f47..60d25a5a76 100644 --- a/internal/api/grpc/management/project_application.go +++ b/internal/api/grpc/management/project_application.go @@ -42,7 +42,11 @@ func (s *Server) ListApps(ctx context.Context, req *mgmt_pb.ListAppsRequest) (*m func (s *Server) ListAppChanges(ctx context.Context, req *mgmt_pb.ListAppChangesRequest) (*mgmt_pb.ListAppChangesResponse, error) { offset, limit, asc := object_grpc.ListQueryToModel(req.Query) - res, err := s.project.ApplicationChanges(ctx, req.ProjectId, req.AppId, offset, limit, asc) + features, err := s.features.GetOrgFeatures(ctx, authz.GetCtxData(ctx).OrgID) + if err != nil { + return nil, err + } + res, err := s.project.ApplicationChanges(ctx, req.ProjectId, req.AppId, offset, limit, asc, features.AuditLogRetention) if err != nil { return nil, err } diff --git a/internal/api/grpc/management/server.go b/internal/api/grpc/management/server.go index c628f12f5d..8a43faf8b6 100644 --- a/internal/api/grpc/management/server.go +++ b/internal/api/grpc/management/server.go @@ -1,6 +1,8 @@ package management import ( + "google.golang.org/grpc" + "github.com/caos/zitadel/internal/api/authz" "github.com/caos/zitadel/internal/api/grpc/server" "github.com/caos/zitadel/internal/command" @@ -9,7 +11,6 @@ import ( "github.com/caos/zitadel/internal/management/repository/eventsourcing" "github.com/caos/zitadel/internal/query" "github.com/caos/zitadel/pkg/grpc/management" - "google.golang.org/grpc" ) const ( @@ -27,6 +28,7 @@ type Server struct { user repository.UserRepository usergrant repository.UserGrantRepository iam repository.IamRepository + features repository.FeaturesRepository authZ authz.Config systemDefaults systemdefaults.SystemDefaults } @@ -44,6 +46,7 @@ func CreateServer(command *command.Commands, query *query.Queries, repo reposito user: repo, usergrant: repo, iam: repo, + features: repo, systemDefaults: sd, } } diff --git a/internal/api/grpc/management/user.go b/internal/api/grpc/management/user.go index eb869dd49b..3e71e5c549 100644 --- a/internal/api/grpc/management/user.go +++ b/internal/api/grpc/management/user.go @@ -53,7 +53,11 @@ func (s *Server) ListUsers(ctx context.Context, req *mgmt_pb.ListUsersRequest) ( func (s *Server) ListUserChanges(ctx context.Context, req *mgmt_pb.ListUserChangesRequest) (*mgmt_pb.ListUserChangesResponse, error) { offset, limit, asc := object.ListQueryToModel(req.Query) - res, err := s.user.UserChanges(ctx, req.UserId, offset, limit, asc) + features, err := s.features.GetOrgFeatures(ctx, authz.GetCtxData(ctx).OrgID) + if err != nil { + return nil, err + } + res, err := s.user.UserChanges(ctx, req.UserId, offset, limit, asc, features.AuditLogRetention) if err != nil { return nil, err } diff --git a/internal/api/grpc/policy/login_policy.go b/internal/api/grpc/policy/login_policy.go index bccc2942ad..ca58a3df83 100644 --- a/internal/api/grpc/policy/login_policy.go +++ b/internal/api/grpc/policy/login_policy.go @@ -11,7 +11,7 @@ func ModelLoginPolicyToPb(policy *model.LoginPolicyView) *policy_pb.LoginPolicy IsDefault: policy.Default, AllowUsernamePassword: policy.AllowUsernamePassword, AllowRegister: policy.AllowRegister, - AllowExternalIdp: policy.AllowRegister, + AllowExternalIdp: policy.AllowExternalIDP, ForceMfa: policy.ForceMFA, PasswordlessType: ModelPasswordlessTypeToPb(policy.PasswordlessType), } diff --git a/internal/api/grpc/server/middleware/auth_interceptor_test.go b/internal/api/grpc/server/middleware/auth_interceptor_test.go index 5f02048f8c..5012771aa1 100644 --- a/internal/api/grpc/server/middleware/auth_interceptor_test.go +++ b/internal/api/grpc/server/middleware/auth_interceptor_test.go @@ -37,6 +37,9 @@ func (v *verifierMock) ExistsOrg(ctx context.Context, orgID string) error { func (v *verifierMock) VerifierClientID(ctx context.Context, appName string) (string, error) { return "", nil } +func (v *verifierMock) CheckOrgFeatures(context.Context, string, ...string) error { + return nil +} func Test_authorize(t *testing.T) { type args struct { diff --git a/internal/auth/repository/eventsourcing/eventstore/features.go b/internal/auth/repository/eventsourcing/eventstore/features.go new file mode 100644 index 0000000000..b3cf660393 --- /dev/null +++ b/internal/auth/repository/eventsourcing/eventstore/features.go @@ -0,0 +1,66 @@ +package eventstore + +import ( + "context" + + "github.com/caos/logging" + + auth_view "github.com/caos/zitadel/internal/auth/repository/eventsourcing/view" + "github.com/caos/zitadel/internal/domain" + "github.com/caos/zitadel/internal/errors" + v1 "github.com/caos/zitadel/internal/eventstore/v1" + "github.com/caos/zitadel/internal/eventstore/v1/models" + features_model "github.com/caos/zitadel/internal/features/model" + "github.com/caos/zitadel/internal/features/repository/view/model" + iam_view "github.com/caos/zitadel/internal/iam/repository/view" +) + +type FeaturesRepo struct { + Eventstore v1.Eventstore + + View *auth_view.View +} + +func (repo *FeaturesRepo) GetDefaultFeatures(ctx context.Context) (*features_model.FeaturesView, error) { + features, viewErr := repo.View.FeaturesByAggregateID(domain.IAMID) + if viewErr != nil && !errors.IsNotFound(viewErr) { + return nil, viewErr + } + if errors.IsNotFound(viewErr) { + features = new(model.FeaturesView) + } + events, esErr := repo.getIAMEvents(ctx, features.Sequence) + if errors.IsNotFound(viewErr) && len(events) == 0 { + return nil, errors.ThrowNotFound(nil, "EVENT-Lsoj7", "Errors.Org.NotFound") + } + if esErr != nil { + logging.Log("EVENT-PSoc3").WithError(esErr).Debug("error retrieving new events") + return model.FeaturesToModel(features), nil + } + featuresCopy := *features + for _, event := range events { + if err := featuresCopy.AppendEvent(event); err != nil { + return model.FeaturesToModel(&featuresCopy), nil + } + } + return model.FeaturesToModel(&featuresCopy), nil +} + +func (repo *FeaturesRepo) GetOrgFeatures(ctx context.Context, orgID string) (*features_model.FeaturesView, error) { + features, err := repo.View.FeaturesByAggregateID(orgID) + if errors.IsNotFound(err) { + return repo.GetDefaultFeatures(ctx) + } + if err != nil { + return nil, err + } + return model.FeaturesToModel(features), nil +} + +func (repo *FeaturesRepo) getIAMEvents(ctx context.Context, sequence uint64) ([]*models.Event, error) { + query, err := iam_view.IAMByIDQuery(domain.IAMID, sequence) + if err != nil { + return nil, err + } + return repo.Eventstore.FilterEvents(ctx, query) +} diff --git a/internal/auth/repository/eventsourcing/eventstore/user.go b/internal/auth/repository/eventsourcing/eventstore/user.go index 99c915db50..6018cbda12 100644 --- a/internal/auth/repository/eventsourcing/eventstore/user.go +++ b/internal/auth/repository/eventsourcing/eventstore/user.go @@ -2,6 +2,8 @@ package eventstore import ( "context" + "time" + "github.com/caos/zitadel/internal/eventstore/v1" "github.com/golang/protobuf/ptypes" @@ -187,8 +189,8 @@ func (repo *UserRepo) UserByLoginName(ctx context.Context, loginname string) (*m } return usr_view_model.UserToModel(&userCopy), nil } -func (repo *UserRepo) MyUserChanges(ctx context.Context, lastSequence uint64, limit uint64, sortAscending bool) (*model.UserChanges, error) { - changes, err := repo.getUserChanges(ctx, authz.GetCtxData(ctx).UserID, lastSequence, limit, sortAscending) +func (repo *UserRepo) MyUserChanges(ctx context.Context, lastSequence uint64, limit uint64, sortAscending bool, retention time.Duration) (*model.UserChanges, error) { + changes, err := repo.getUserChanges(ctx, authz.GetCtxData(ctx).UserID, lastSequence, limit, sortAscending, retention) if err != nil { return nil, err } @@ -215,8 +217,8 @@ func (repo *UserRepo) MachineKeyByID(ctx context.Context, keyID string) (*key_mo return key_view_model.AuthNKeyToModel(key), nil } -func (r *UserRepo) getUserChanges(ctx context.Context, userID string, lastSequence uint64, limit uint64, sortAscending bool) (*model.UserChanges, error) { - query := usr_view.ChangesQuery(userID, lastSequence, limit, sortAscending) +func (r *UserRepo) getUserChanges(ctx context.Context, userID string, lastSequence uint64, limit uint64, sortAscending bool, retention time.Duration) (*model.UserChanges, error) { + query := usr_view.ChangesQuery(userID, lastSequence, limit, sortAscending, retention) events, err := r.Eventstore.FilterEvents(ctx, query) if err != nil { diff --git a/internal/auth/repository/eventsourcing/handler/features.go b/internal/auth/repository/eventsourcing/handler/features.go new file mode 100644 index 0000000000..b025649528 --- /dev/null +++ b/internal/auth/repository/eventsourcing/handler/features.go @@ -0,0 +1,165 @@ +package handler + +import ( + "context" + + "github.com/caos/logging" + + "github.com/caos/zitadel/internal/domain" + caos_errs "github.com/caos/zitadel/internal/errors" + "github.com/caos/zitadel/internal/eventstore/v1" + es_models "github.com/caos/zitadel/internal/eventstore/v1/models" + "github.com/caos/zitadel/internal/eventstore/v1/query" + "github.com/caos/zitadel/internal/eventstore/v1/spooler" + "github.com/caos/zitadel/internal/features/repository/view/model" + "github.com/caos/zitadel/internal/iam/repository/eventsourcing" + iam_es_model "github.com/caos/zitadel/internal/iam/repository/eventsourcing/model" + org_es_model "github.com/caos/zitadel/internal/org/repository/eventsourcing/model" + iam_repo "github.com/caos/zitadel/internal/repository/iam" + org_repo "github.com/caos/zitadel/internal/repository/org" +) + +const ( + featuresTable = "auth.features" +) + +type Features struct { + handler + subscription *v1.Subscription +} + +func newFeatures(handler handler) *Features { + h := &Features{ + handler: handler, + } + + h.subscribe() + + return h +} + +func (p *Features) subscribe() { + p.subscription = p.es.Subscribe(p.AggregateTypes()...) + go func() { + for event := range p.subscription.Events { + query.ReduceEvent(p, event) + } + }() +} + +func (p *Features) ViewModel() string { + return featuresTable +} + +func (p *Features) AggregateTypes() []es_models.AggregateType { + return []es_models.AggregateType{iam_es_model.IAMAggregate, org_es_model.OrgAggregate} +} + +func (p *Features) EventQuery() (*es_models.SearchQuery, error) { + sequence, err := p.view.GetLatestFeaturesSequence() + if err != nil { + return nil, err + } + return es_models.NewSearchQuery(). + AggregateTypeFilter(p.AggregateTypes()...). + LatestSequenceFilter(sequence.CurrentSequence), nil +} + +func (p *Features) CurrentSequence() (uint64, error) { + sequence, err := p.view.GetLatestFeaturesSequence() + if err != nil { + return 0, err + } + return sequence.CurrentSequence, nil +} + +func (p *Features) Reduce(event *es_models.Event) (err error) { + switch event.AggregateType { + case org_es_model.OrgAggregate, iam_es_model.IAMAggregate: + err = p.processFeatures(event) + } + return err +} + +func (p *Features) processFeatures(event *es_models.Event) (err error) { + features := new(model.FeaturesView) + switch string(event.Type) { + case string(org_es_model.OrgAdded): + features, err = p.getDefaultFeatures() + if err != nil { + return err + } + features.AggregateID = event.AggregateID + features.Default = true + case string(iam_repo.FeaturesSetEventType): + defaultFeatures, err := p.view.AllDefaultFeatures() + if err != nil { + return err + } + for _, features := range defaultFeatures { + err = features.AppendEvent(event) + if err != nil { + return err + } + } + return p.view.PutFeaturesList(defaultFeatures, event) + case string(org_repo.FeaturesSetEventType): + features, err = p.view.FeaturesByAggregateID(event.AggregateID) + if err != nil { + return err + } + err = features.AppendEvent(event) + case string(org_repo.FeaturesRemovedEventType): + features, err = p.getDefaultFeatures() + if err != nil { + return err + } + features.AggregateID = event.AggregateID + features.Default = true + default: + return p.view.ProcessedFeaturesSequence(event) + } + if err != nil { + return err + } + return p.view.PutFeatures(features, event) +} + +func (p *Features) OnError(event *es_models.Event, err error) error { + logging.LogWithFields("SPOOL-Wj8sf", "id", event.AggregateID).WithError(err).Warn("something went wrong in login features handler") + return spooler.HandleError(event, err, p.view.GetLatestFeaturesFailedEvent, p.view.ProcessedFeaturesFailedEvent, p.view.ProcessedFeaturesSequence, p.errorCountUntilSkip) +} + +func (p *Features) OnSuccess() error { + return spooler.HandleSuccess(p.view.UpdateFeaturesSpoolerRunTimestamp) +} + +func (p *Features) getDefaultFeatures() (*model.FeaturesView, error) { + features, featuresErr := p.view.FeaturesByAggregateID(domain.IAMID) + if featuresErr != nil && !caos_errs.IsNotFound(featuresErr) { + return nil, featuresErr + } + if features == nil { + features = &model.FeaturesView{} + } + events, err := p.getIAMEvents(features.Sequence) + if err != nil { + return features, featuresErr + } + featuresCopy := *features + for _, event := range events { + if err := featuresCopy.AppendEvent(event); err != nil { + return features, nil + } + } + return &featuresCopy, nil +} + +func (p *Features) getIAMEvents(sequence uint64) ([]*es_models.Event, error) { + query, err := eventsourcing.IAMByIDQuery(domain.IAMID, sequence) + if err != nil { + return nil, err + } + + return p.es.FilterEvents(context.Background(), query) +} diff --git a/internal/auth/repository/eventsourcing/handler/handler.go b/internal/auth/repository/eventsourcing/handler/handler.go index 7b6c49585e..7c59700081 100644 --- a/internal/auth/repository/eventsourcing/handler/handler.go +++ b/internal/auth/repository/eventsourcing/handler/handler.go @@ -68,6 +68,7 @@ func Register(configs Configs, bulkLimit, errorCount uint64, view *view.View, es handler{view, bulkLimit, configs.cycleDuration("OrgIAMPolicy"), errorCount, es}), newProjectRole(handler{view, bulkLimit, configs.cycleDuration("ProjectRole"), errorCount, es}), newLabelPolicy(handler{view, bulkLimit, configs.cycleDuration("LabelPolicy"), errorCount, es}), + newFeatures(handler{view, bulkLimit, configs.cycleDuration("Features"), errorCount, es}), } } diff --git a/internal/auth/repository/eventsourcing/repository.go b/internal/auth/repository/eventsourcing/repository.go index 822b75c6d8..3259bde2de 100644 --- a/internal/auth/repository/eventsourcing/repository.go +++ b/internal/auth/repository/eventsourcing/repository.go @@ -42,6 +42,7 @@ type EsRepository struct { eventstore.UserGrantRepo eventstore.OrgRepository eventstore.IAMRepository + eventstore.FeaturesRepo } func Start(conf Config, authZ authz.Config, systemDefaults sd.SystemDefaults, command *command.Commands, queries *query.Queries, authZRepo *authz_repo.EsRepository, esV2 *es2.Eventstore) (*EsRepository, error) { @@ -142,6 +143,10 @@ func Start(conf Config, authZ authz.Config, systemDefaults sd.SystemDefaults, co IAMID: systemDefaults.IamID, IAMV2QuerySide: queries, }, + eventstore.FeaturesRepo{ + Eventstore: es, + View: view, + }, }, nil } diff --git a/internal/auth/repository/eventsourcing/view/features.go b/internal/auth/repository/eventsourcing/view/features.go new file mode 100644 index 0000000000..2849f84ad1 --- /dev/null +++ b/internal/auth/repository/eventsourcing/view/features.go @@ -0,0 +1,56 @@ +package view + +import ( + "github.com/caos/zitadel/internal/eventstore/v1/models" + "github.com/caos/zitadel/internal/features/repository/view" + "github.com/caos/zitadel/internal/features/repository/view/model" + global_view "github.com/caos/zitadel/internal/view/repository" +) + +const ( + featuresTable = "auth.features" +) + +func (v *View) AllDefaultFeatures() ([]*model.FeaturesView, error) { + return view.GetDefaultFeatures(v.Db, featuresTable) +} + +func (v *View) FeaturesByAggregateID(aggregateID string) (*model.FeaturesView, error) { + return view.GetFeaturesByAggregateID(v.Db, featuresTable, aggregateID) +} + +func (v *View) PutFeatures(features *model.FeaturesView, event *models.Event) error { + err := view.PutFeatures(v.Db, featuresTable, features) + if err != nil { + return err + } + return v.ProcessedFeaturesSequence(event) +} + +func (v *View) PutFeaturesList(features []*model.FeaturesView, event *models.Event) error { + err := view.PutFeaturesList(v.Db, featuresTable, features...) + if err != nil { + return err + } + return v.ProcessedFeaturesSequence(event) +} + +func (v *View) GetLatestFeaturesSequence() (*global_view.CurrentSequence, error) { + return v.latestSequence(featuresTable) +} + +func (v *View) ProcessedFeaturesSequence(event *models.Event) error { + return v.saveCurrentSequence(featuresTable, event) +} + +func (v *View) UpdateFeaturesSpoolerRunTimestamp() error { + return v.updateSpoolerRunSequence(featuresTable) +} + +func (v *View) GetLatestFeaturesFailedEvent(sequence uint64) (*global_view.FailedEvent, error) { + return v.latestFailedEvent(featuresTable, sequence) +} + +func (v *View) ProcessedFeaturesFailedEvent(failedEvent *global_view.FailedEvent) error { + return v.saveFailedEvent(failedEvent) +} diff --git a/internal/auth/repository/features.go b/internal/auth/repository/features.go new file mode 100644 index 0000000000..1c01ab4d00 --- /dev/null +++ b/internal/auth/repository/features.go @@ -0,0 +1,11 @@ +package repository + +import ( + "context" + + features_model "github.com/caos/zitadel/internal/features/model" +) + +type FeaturesRepository interface { + GetOrgFeatures(ctx context.Context, id string) (*features_model.FeaturesView, error) +} diff --git a/internal/auth/repository/repository.go b/internal/auth/repository/repository.go index cbb872ded8..28888ad678 100644 --- a/internal/auth/repository/repository.go +++ b/internal/auth/repository/repository.go @@ -16,4 +16,5 @@ type Repository interface { UserGrantRepository OrgRepository IAMRepository + FeaturesRepository } diff --git a/internal/auth/repository/user.go b/internal/auth/repository/user.go index 91cc618366..9a624f50f2 100644 --- a/internal/auth/repository/user.go +++ b/internal/auth/repository/user.go @@ -2,6 +2,7 @@ package repository import ( "context" + "time" key_model "github.com/caos/zitadel/internal/key/model" @@ -36,7 +37,7 @@ type myUserRepo interface { GetMyPasswordless(ctx context.Context) ([]*model.WebAuthNView, error) - MyUserChanges(ctx context.Context, lastSequence uint64, limit uint64, sortAscending bool) (*model.UserChanges, error) + MyUserChanges(ctx context.Context, lastSequence uint64, limit uint64, sortAscending bool, retention time.Duration) (*model.UserChanges, error) SearchMyUserMemberships(ctx context.Context, request *model.UserMembershipSearchRequest) (*model.UserMembershipSearchResponse, error) } diff --git a/internal/authz/repository/eventsourcing/eventstore/token_verifier.go b/internal/authz/repository/eventsourcing/eventstore/token_verifier.go index fabfdd27a4..cf65ea3a4e 100644 --- a/internal/authz/repository/eventsourcing/eventstore/token_verifier.go +++ b/internal/authz/repository/eventsourcing/eventstore/token_verifier.go @@ -2,26 +2,26 @@ package eventstore import ( "context" - "github.com/caos/zitadel/internal/domain" - "github.com/caos/zitadel/internal/eventstore/v1" - es_sdk "github.com/caos/zitadel/internal/eventstore/v1/sdk" - iam_model "github.com/caos/zitadel/internal/iam/model" - iam_es_model "github.com/caos/zitadel/internal/iam/repository/eventsourcing/model" - iam_view "github.com/caos/zitadel/internal/iam/repository/view" - "k8s.io/apimachinery/pkg/api/errors" "strings" "time" - "github.com/caos/zitadel/internal/eventstore/v1/models" - usr_view "github.com/caos/zitadel/internal/user/repository/view" - "github.com/caos/logging" + "k8s.io/apimachinery/pkg/api/errors" "github.com/caos/zitadel/internal/authz/repository/eventsourcing/view" "github.com/caos/zitadel/internal/crypto" + "github.com/caos/zitadel/internal/domain" caos_errs "github.com/caos/zitadel/internal/errors" + "github.com/caos/zitadel/internal/eventstore/v1" + "github.com/caos/zitadel/internal/eventstore/v1/models" + es_sdk "github.com/caos/zitadel/internal/eventstore/v1/sdk" + features_view_model "github.com/caos/zitadel/internal/features/repository/view/model" + iam_model "github.com/caos/zitadel/internal/iam/model" + iam_es_model "github.com/caos/zitadel/internal/iam/repository/eventsourcing/model" + iam_view "github.com/caos/zitadel/internal/iam/repository/view" "github.com/caos/zitadel/internal/telemetry/tracing" usr_model "github.com/caos/zitadel/internal/user/model" + usr_view "github.com/caos/zitadel/internal/user/repository/view" "github.com/caos/zitadel/internal/user/repository/view/model" ) @@ -111,6 +111,68 @@ func (repo *TokenVerifierRepo) ExistsOrg(ctx context.Context, orgID string) erro return err } +func (repo *TokenVerifierRepo) CheckOrgFeatures(ctx context.Context, orgID string, requiredFeatures ...string) error { + features, err := repo.View.FeaturesByAggregateID(orgID) + if caos_errs.IsNotFound(err) { + return repo.checkDefaultFeatures(ctx, requiredFeatures...) + } + if err != nil { + return err + } + return checkFeatures(features, requiredFeatures...) +} + +func checkFeatures(features *features_view_model.FeaturesView, requiredFeatures ...string) error { + for _, requiredFeature := range requiredFeatures { + if strings.HasPrefix(requiredFeature, domain.FeatureLoginPolicy) { + if err := checkLoginPolicyFeatures(features, requiredFeature); err != nil { + return err + } + } + if requiredFeature == domain.FeaturePasswordComplexityPolicy && !features.PasswordComplexityPolicy { + return MissingFeatureErr(requiredFeature) + } + if requiredFeature == domain.FeatureLabelPolicy && !features.PasswordComplexityPolicy { + return MissingFeatureErr(requiredFeature) + } + } + return nil +} + +func checkLoginPolicyFeatures(features *features_view_model.FeaturesView, requiredFeature string) error { + switch requiredFeature { + case domain.FeatureLoginPolicyFactors: + if !features.LoginPolicyFactors { + return MissingFeatureErr(requiredFeature) + } + case domain.FeatureLoginPolicyIDP: + if !features.LoginPolicyIDP { + return MissingFeatureErr(requiredFeature) + } + case domain.FeatureLoginPolicyPasswordless: + if !features.LoginPolicyPasswordless { + return MissingFeatureErr(requiredFeature) + } + case domain.FeatureLoginPolicyRegistration: + if !features.LoginPolicyRegistration { + return MissingFeatureErr(requiredFeature) + } + case domain.FeatureLoginPolicyUsernameLogin: + if !features.LoginPolicyUsernameLogin { + return MissingFeatureErr(requiredFeature) + } + default: + if !features.LoginPolicyFactors && !features.LoginPolicyIDP && !features.LoginPolicyPasswordless && !features.LoginPolicyRegistration && !features.LoginPolicyUsernameLogin { + return MissingFeatureErr(requiredFeature) + } + } + return nil +} + +func MissingFeatureErr(feature string) error { + return caos_errs.ThrowPermissionDeniedf(nil, "AUTH-Dvgsf", "missing feature %v", feature) +} + func (repo *TokenVerifierRepo) VerifierClientID(ctx context.Context, appName string) (_ string, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -150,3 +212,36 @@ func (u *TokenVerifierRepo) getIAMByID(ctx context.Context) (*iam_model.IAM, err } return iam_es_model.IAMToModel(iam), nil } + +func (repo *TokenVerifierRepo) checkDefaultFeatures(ctx context.Context, requiredFeatures ...string) error { + features, viewErr := repo.View.FeaturesByAggregateID(domain.IAMID) + if viewErr != nil && !errors.IsNotFound(viewErr) { + return viewErr + } + if errors.IsNotFound(viewErr) { + features = new(features_view_model.FeaturesView) + } + events, esErr := repo.getIAMEvents(ctx, features.Sequence) + if errors.IsNotFound(viewErr) && len(events) == 0 { + return checkFeatures(features, requiredFeatures...) + } + if esErr != nil { + logging.Log("EVENT-PSoc3").WithError(esErr).Debug("error retrieving new events") + return esErr + } + featuresCopy := *features + for _, event := range events { + if err := featuresCopy.AppendEvent(event); err != nil { + return checkFeatures(features, requiredFeatures...) + } + } + return checkFeatures(&featuresCopy, requiredFeatures...) +} + +func (repo *TokenVerifierRepo) getIAMEvents(ctx context.Context, sequence uint64) ([]*models.Event, error) { + query, err := iam_view.IAMByIDQuery(domain.IAMID, sequence) + if err != nil { + return nil, err + } + return repo.Eventstore.FilterEvents(ctx, query) +} diff --git a/internal/authz/repository/eventsourcing/handler/features.go b/internal/authz/repository/eventsourcing/handler/features.go new file mode 100644 index 0000000000..c558acd388 --- /dev/null +++ b/internal/authz/repository/eventsourcing/handler/features.go @@ -0,0 +1,165 @@ +package handler + +import ( + "context" + + "github.com/caos/logging" + + "github.com/caos/zitadel/internal/domain" + caos_errs "github.com/caos/zitadel/internal/errors" + "github.com/caos/zitadel/internal/eventstore/v1" + es_models "github.com/caos/zitadel/internal/eventstore/v1/models" + "github.com/caos/zitadel/internal/eventstore/v1/query" + "github.com/caos/zitadel/internal/eventstore/v1/spooler" + "github.com/caos/zitadel/internal/features/repository/view/model" + "github.com/caos/zitadel/internal/iam/repository/eventsourcing" + iam_es_model "github.com/caos/zitadel/internal/iam/repository/eventsourcing/model" + org_es_model "github.com/caos/zitadel/internal/org/repository/eventsourcing/model" + iam_repo "github.com/caos/zitadel/internal/repository/iam" + org_repo "github.com/caos/zitadel/internal/repository/org" +) + +const ( + featuresTable = "authz.features" +) + +type Features struct { + handler + subscription *v1.Subscription +} + +func newFeatures(handler handler) *Features { + h := &Features{ + handler: handler, + } + + h.subscribe() + + return h +} + +func (p *Features) subscribe() { + p.subscription = p.es.Subscribe(p.AggregateTypes()...) + go func() { + for event := range p.subscription.Events { + query.ReduceEvent(p, event) + } + }() +} + +func (p *Features) ViewModel() string { + return featuresTable +} + +func (p *Features) AggregateTypes() []es_models.AggregateType { + return []es_models.AggregateType{iam_es_model.IAMAggregate, org_es_model.OrgAggregate} +} + +func (p *Features) EventQuery() (*es_models.SearchQuery, error) { + sequence, err := p.view.GetLatestFeaturesSequence() + if err != nil { + return nil, err + } + return es_models.NewSearchQuery(). + AggregateTypeFilter(p.AggregateTypes()...). + LatestSequenceFilter(sequence.CurrentSequence), nil +} + +func (p *Features) CurrentSequence() (uint64, error) { + sequence, err := p.view.GetLatestFeaturesSequence() + if err != nil { + return 0, err + } + return sequence.CurrentSequence, nil +} + +func (p *Features) Reduce(event *es_models.Event) (err error) { + switch event.AggregateType { + case org_es_model.OrgAggregate, iam_es_model.IAMAggregate: + err = p.processFeatures(event) + } + return err +} + +func (p *Features) processFeatures(event *es_models.Event) (err error) { + features := new(model.FeaturesView) + switch string(event.Type) { + case string(org_es_model.OrgAdded): + features, err = p.getDefaultFeatures() + if err != nil { + return err + } + features.AggregateID = event.AggregateID + features.Default = true + case string(iam_repo.FeaturesSetEventType): + defaultFeatures, err := p.view.AllDefaultFeatures() + if err != nil { + return err + } + for _, features := range defaultFeatures { + err = features.AppendEvent(event) + if err != nil { + return err + } + } + return p.view.PutFeaturesList(defaultFeatures, event) + case string(org_repo.FeaturesSetEventType): + features, err = p.view.FeaturesByAggregateID(event.AggregateID) + if err != nil { + return err + } + err = features.AppendEvent(event) + case string(org_repo.FeaturesRemovedEventType): + features, err = p.getDefaultFeatures() + if err != nil { + return err + } + features.AggregateID = event.AggregateID + features.Default = true + default: + return p.view.ProcessedFeaturesSequence(event) + } + if err != nil { + return err + } + return p.view.PutFeatures(features, event) +} + +func (p *Features) OnError(event *es_models.Event, err error) error { + logging.LogWithFields("SPOOL-Wj8sf", "id", event.AggregateID).WithError(err).Warn("something went wrong in login features handler") + return spooler.HandleError(event, err, p.view.GetLatestFeaturesFailedEvent, p.view.ProcessedFeaturesFailedEvent, p.view.ProcessedFeaturesSequence, p.errorCountUntilSkip) +} + +func (p *Features) OnSuccess() error { + return spooler.HandleSuccess(p.view.UpdateFeaturesSpoolerRunTimestamp) +} + +func (p *Features) getDefaultFeatures() (*model.FeaturesView, error) { + features, featuresErr := p.view.FeaturesByAggregateID(domain.IAMID) + if featuresErr != nil && !caos_errs.IsNotFound(featuresErr) { + return nil, featuresErr + } + if features == nil { + features = &model.FeaturesView{} + } + events, err := p.getIAMEvents(features.Sequence) + if err != nil { + return features, featuresErr + } + featuresCopy := *features + for _, event := range events { + if err := featuresCopy.AppendEvent(event); err != nil { + return features, nil + } + } + return &featuresCopy, nil +} + +func (p *Features) getIAMEvents(sequence uint64) ([]*es_models.Event, error) { + query, err := eventsourcing.IAMByIDQuery(domain.IAMID, sequence) + if err != nil { + return nil, err + } + + return p.es.FilterEvents(context.Background(), query) +} diff --git a/internal/authz/repository/eventsourcing/handler/handler.go b/internal/authz/repository/eventsourcing/handler/handler.go index f3a32c9cba..1664e93ff9 100644 --- a/internal/authz/repository/eventsourcing/handler/handler.go +++ b/internal/authz/repository/eventsourcing/handler/handler.go @@ -1,9 +1,10 @@ package handler import ( - "github.com/caos/zitadel/internal/eventstore/v1" "time" + "github.com/caos/zitadel/internal/eventstore/v1" + "github.com/caos/zitadel/internal/authz/repository/eventsourcing/view" sd "github.com/caos/zitadel/internal/config/systemdefaults" "github.com/caos/zitadel/internal/config/types" @@ -40,6 +41,8 @@ func Register(configs Configs, bulkLimit, errorCount uint64, view *view.View, es handler{view, bulkLimit, configs.cycleDuration("Application"), errorCount, es}), newOrg( handler{view, bulkLimit, configs.cycleDuration("Org"), errorCount, es}), + newFeatures( + handler{view, bulkLimit, configs.cycleDuration("Features"), errorCount, es}), } } diff --git a/internal/authz/repository/eventsourcing/view/features.go b/internal/authz/repository/eventsourcing/view/features.go new file mode 100644 index 0000000000..65cb93e99d --- /dev/null +++ b/internal/authz/repository/eventsourcing/view/features.go @@ -0,0 +1,56 @@ +package view + +import ( + "github.com/caos/zitadel/internal/eventstore/v1/models" + "github.com/caos/zitadel/internal/features/repository/view" + "github.com/caos/zitadel/internal/features/repository/view/model" + global_view "github.com/caos/zitadel/internal/view/repository" +) + +const ( + featuresTable = "authz.features" +) + +func (v *View) AllDefaultFeatures() ([]*model.FeaturesView, error) { + return view.GetDefaultFeatures(v.Db, featuresTable) +} + +func (v *View) FeaturesByAggregateID(aggregateID string) (*model.FeaturesView, error) { + return view.GetFeaturesByAggregateID(v.Db, featuresTable, aggregateID) +} + +func (v *View) PutFeatures(features *model.FeaturesView, event *models.Event) error { + err := view.PutFeatures(v.Db, featuresTable, features) + if err != nil { + return err + } + return v.ProcessedFeaturesSequence(event) +} + +func (v *View) PutFeaturesList(features []*model.FeaturesView, event *models.Event) error { + err := view.PutFeaturesList(v.Db, featuresTable, features...) + if err != nil { + return err + } + return v.ProcessedFeaturesSequence(event) +} + +func (v *View) GetLatestFeaturesSequence() (*global_view.CurrentSequence, error) { + return v.latestSequence(featuresTable) +} + +func (v *View) ProcessedFeaturesSequence(event *models.Event) error { + return v.saveCurrentSequence(featuresTable, event) +} + +func (v *View) UpdateFeaturesSpoolerRunTimestamp() error { + return v.updateSpoolerRunSequence(featuresTable) +} + +func (v *View) GetLatestFeaturesFailedEvent(sequence uint64) (*global_view.FailedEvent, error) { + return v.latestFailedEvent(featuresTable, sequence) +} + +func (v *View) ProcessedFeaturesFailedEvent(failedEvent *global_view.FailedEvent) error { + return v.saveFailedEvent(failedEvent) +} diff --git a/internal/command/command.go b/internal/command/command.go index 11a2117e41..913e9ec2c4 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -3,6 +3,7 @@ package command import ( "context" "github.com/caos/zitadel/internal/api/authz" + authz_repo "github.com/caos/zitadel/internal/authz/repository/eventsourcing" "github.com/caos/zitadel/internal/config/types" "github.com/caos/zitadel/internal/domain" "github.com/caos/zitadel/internal/eventstore" @@ -49,13 +50,14 @@ type Commands struct { keyAlgorithm crypto.EncryptionAlgorithm privateKeyLifetime time.Duration publicKeyLifetime time.Duration + tokenVerifier *authz.TokenVerifier } type Config struct { Eventstore types.SQLUser } -func StartCommands(eventstore *eventstore.Eventstore, defaults sd.SystemDefaults, authZConfig authz.Config) (repo *Commands, err error) { +func StartCommands(eventstore *eventstore.Eventstore, defaults sd.SystemDefaults, authZConfig authz.Config, authZRepo *authz_repo.EsRepository) (repo *Commands, err error) { repo = &Commands{ eventstore: eventstore, idGenerator: id.SonyFlakeGenerator, @@ -119,6 +121,8 @@ func StartCommands(eventstore *eventstore.Eventstore, defaults sd.SystemDefaults return nil, err } repo.keyAlgorithm = keyAlgorithm + + repo.tokenVerifier = authz.Start(authZRepo) return repo, nil } diff --git a/internal/command/features_model.go b/internal/command/features_model.go new file mode 100644 index 0000000000..0027e1baa1 --- /dev/null +++ b/internal/command/features_model.go @@ -0,0 +1,74 @@ +package command + +import ( + "time" + + "github.com/caos/zitadel/internal/domain" + "github.com/caos/zitadel/internal/eventstore" + "github.com/caos/zitadel/internal/repository/features" +) + +type FeaturesWriteModel struct { + eventstore.WriteModel + + TierName string + TierDescription string + State domain.FeaturesState + StateDescription string + AuditLogRetention time.Duration + LoginPolicyFactors bool + LoginPolicyIDP bool + LoginPolicyPasswordless bool + LoginPolicyRegistration bool + LoginPolicyUsernameLogin bool + PasswordComplexityPolicy bool + LabelPolicy bool +} + +func (wm *FeaturesWriteModel) Reduce() error { + for _, event := range wm.Events { + switch e := event.(type) { + case *features.FeaturesSetEvent: + if e.TierName != nil { + wm.TierName = *e.TierName + } + if e.TierDescription != nil { + wm.TierDescription = *e.TierDescription + } + wm.State = domain.FeaturesStateActive + if e.State != nil { + wm.State = *e.State + } + if e.StateDescription != nil { + wm.StateDescription = *e.StateDescription + } + if e.AuditLogRetention != nil { + wm.AuditLogRetention = *e.AuditLogRetention + } + if e.LoginPolicyFactors != nil { + wm.LoginPolicyFactors = *e.LoginPolicyFactors + } + if e.LoginPolicyIDP != nil { + wm.LoginPolicyIDP = *e.LoginPolicyIDP + } + if e.LoginPolicyPasswordless != nil { + wm.LoginPolicyPasswordless = *e.LoginPolicyPasswordless + } + if e.LoginPolicyRegistration != nil { + wm.LoginPolicyRegistration = *e.LoginPolicyRegistration + } + if e.LoginPolicyUsernameLogin != nil { + wm.LoginPolicyUsernameLogin = *e.LoginPolicyUsernameLogin + } + if e.PasswordComplexityPolicy != nil { + wm.PasswordComplexityPolicy = *e.PasswordComplexityPolicy + } + if e.LabelPolicy != nil { + wm.LabelPolicy = *e.LabelPolicy + } + case *features.FeaturesRemovedEvent: + wm.State = domain.FeaturesStateRemoved + } + } + return wm.WriteModel.Reduce() +} diff --git a/internal/command/iam_converter.go b/internal/command/iam_converter.go index 886616becc..020306069b 100644 --- a/internal/command/iam_converter.go +++ b/internal/command/iam_converter.go @@ -160,3 +160,21 @@ func writeModelToIDPProvider(wm *IdentityProviderWriteModel) *domain.IDPProvider Type: wm.IDPProviderType, } } + +func writeModelToFeatures(wm *FeaturesWriteModel) *domain.Features { + return &domain.Features{ + ObjectRoot: writeModelToObjectRoot(wm.WriteModel), + TierName: wm.TierName, + TierDescription: wm.TierDescription, + State: wm.State, + StateDescription: wm.StateDescription, + AuditLogRetention: wm.AuditLogRetention, + LoginPolicyFactors: wm.LoginPolicyFactors, + LoginPolicyIDP: wm.LoginPolicyIDP, + LoginPolicyPasswordless: wm.LoginPolicyPasswordless, + LoginPolicyRegistration: wm.LoginPolicyRegistration, + LoginPolicyUsernameLogin: wm.LoginPolicyUsernameLogin, + PasswordComplexityPolicy: wm.PasswordComplexityPolicy, + LabelPolicy: wm.LabelPolicy, + } +} diff --git a/internal/command/iam_features.go b/internal/command/iam_features.go new file mode 100644 index 0000000000..6e0758078f --- /dev/null +++ b/internal/command/iam_features.go @@ -0,0 +1,64 @@ +package command + +import ( + "context" + + caos_errs "github.com/caos/zitadel/internal/errors" + "github.com/caos/zitadel/internal/repository/iam" + + "github.com/caos/zitadel/internal/domain" +) + +func (c *Commands) SetDefaultFeatures(ctx context.Context, features *domain.Features) (*domain.ObjectDetails, error) { + existingFeatures := NewIAMFeaturesWriteModel() + setEvent, err := c.setDefaultFeatures(ctx, existingFeatures, features) + if err != nil { + return nil, err + } + + pushedEvents, err := c.eventstore.PushEvents(ctx, setEvent) + if err != nil { + return nil, err + } + err = AppendAndReduce(existingFeatures, pushedEvents...) + if err != nil { + return nil, err + } + return writeModelToObjectDetails(&existingFeatures.WriteModel), nil +} + +func (c *Commands) setDefaultFeatures(ctx context.Context, existingFeatures *IAMFeaturesWriteModel, features *domain.Features) (*iam.FeaturesSetEvent, error) { + err := c.eventstore.FilterToQueryReducer(ctx, existingFeatures) + if err != nil { + return nil, err + } + setEvent, hasChanged := existingFeatures.NewSetEvent( + ctx, + IAMAggregateFromWriteModel(&existingFeatures.FeaturesWriteModel.WriteModel), + features.TierName, + features.TierDescription, + features.State, + features.StateDescription, + features.AuditLogRetention, + features.LoginPolicyFactors, + features.LoginPolicyIDP, + features.LoginPolicyPasswordless, + features.LoginPolicyRegistration, + features.LoginPolicyUsernameLogin, + features.PasswordComplexityPolicy, + features.LabelPolicy, + ) + if !hasChanged { + return nil, caos_errs.ThrowPreconditionFailed(nil, "Features-GE4h2", "Errors.Features.NotChanged") + } + return setEvent, nil +} + +func (c *Commands) getDefaultFeatures(ctx context.Context) (*domain.Features, error) { + existingFeatures := NewIAMFeaturesWriteModel() + err := c.eventstore.FilterToQueryReducer(ctx, existingFeatures) + if err != nil { + return nil, err + } + return writeModelToFeatures(&existingFeatures.FeaturesWriteModel), nil +} diff --git a/internal/command/iam_features_model.go b/internal/command/iam_features_model.go new file mode 100644 index 0000000000..431848696a --- /dev/null +++ b/internal/command/iam_features_model.go @@ -0,0 +1,115 @@ +package command + +import ( + "context" + "time" + + "github.com/caos/zitadel/internal/domain" + "github.com/caos/zitadel/internal/eventstore" + "github.com/caos/zitadel/internal/repository/features" + "github.com/caos/zitadel/internal/repository/iam" +) + +type IAMFeaturesWriteModel struct { + FeaturesWriteModel +} + +func NewIAMFeaturesWriteModel() *IAMFeaturesWriteModel { + return &IAMFeaturesWriteModel{ + FeaturesWriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: domain.IAMID, + ResourceOwner: domain.IAMID, + }, + }, + } +} + +func (wm *IAMFeaturesWriteModel) AppendEvents(events ...eventstore.EventReader) { + for _, event := range events { + switch e := event.(type) { + case *iam.FeaturesSetEvent: + wm.FeaturesWriteModel.AppendEvents(&e.FeaturesSetEvent) + } + } +} + +func (wm *IAMFeaturesWriteModel) IsValid() bool { + return wm.AggregateID != "" +} + +func (wm *IAMFeaturesWriteModel) Reduce() error { + return wm.FeaturesWriteModel.Reduce() +} + +func (wm *IAMFeaturesWriteModel) Query() *eventstore.SearchQueryBuilder { + return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent, iam.AggregateType). + AggregateIDs(wm.FeaturesWriteModel.AggregateID). + ResourceOwner(wm.ResourceOwner). + EventTypes(iam.FeaturesSetEventType) +} + +func (wm *IAMFeaturesWriteModel) NewSetEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + tierName, tierDescription string, + state domain.FeaturesState, + stateDescription string, + auditLogRetention time.Duration, + loginPolicyFactors, + loginPolicyIDP, + loginPolicyPasswordless, + loginPolicyRegistration, + loginPolicyUsernameLogin, + passwordComplexityPolicy, + labelPolicy bool, +) (*iam.FeaturesSetEvent, bool) { + + changes := make([]features.FeaturesChanges, 0) + + if tierName != "" && wm.TierName != tierName { + changes = append(changes, features.ChangeTierName(tierName)) + } + if tierDescription != "" && wm.TierDescription != tierDescription { + changes = append(changes, features.ChangeTierDescription(tierDescription)) + } + if wm.State != state { + changes = append(changes, features.ChangeState(state)) + } + if stateDescription != "" && wm.StateDescription != stateDescription { + changes = append(changes, features.ChangeStateDescription(stateDescription)) + } + if auditLogRetention != 0 && wm.AuditLogRetention != auditLogRetention { + changes = append(changes, features.ChangeAuditLogRetention(auditLogRetention)) + } + if wm.LoginPolicyFactors != loginPolicyFactors { + changes = append(changes, features.ChangeLoginPolicyFactors(loginPolicyFactors)) + } + if wm.LoginPolicyIDP != loginPolicyIDP { + changes = append(changes, features.ChangeLoginPolicyIDP(loginPolicyIDP)) + } + if wm.LoginPolicyPasswordless != loginPolicyPasswordless { + changes = append(changes, features.ChangeLoginPolicyPasswordless(loginPolicyPasswordless)) + } + if wm.LoginPolicyRegistration != loginPolicyRegistration { + changes = append(changes, features.ChangeLoginPolicyRegistration(loginPolicyRegistration)) + } + if wm.LoginPolicyUsernameLogin != loginPolicyUsernameLogin { + changes = append(changes, features.ChangeLoginPolicyUsernameLogin(loginPolicyUsernameLogin)) + } + if wm.PasswordComplexityPolicy != passwordComplexityPolicy { + changes = append(changes, features.ChangePasswordComplexityPolicy(passwordComplexityPolicy)) + } + if wm.LabelPolicy != labelPolicy { + changes = append(changes, features.ChangeLabelPolicy(labelPolicy)) + } + + if len(changes) == 0 { + return nil, false + } + changedEvent, err := iam.NewFeaturesSetEvent(ctx, aggregate, changes) + if err != nil { + return nil, false + } + return changedEvent, true +} diff --git a/internal/command/iam_policy_login.go b/internal/command/iam_policy_login.go index b9563dda9d..170793a00c 100644 --- a/internal/command/iam_policy_login.go +++ b/internal/command/iam_policy_login.go @@ -221,7 +221,7 @@ func (c *Commands) AddMultiFactorToDefaultLoginPolicy(ctx context.Context, multi return domain.MultiFactorTypeUnspecified, nil, caos_errs.ThrowInvalidArgument(nil, "IAM-5m9fs", "Errors.IAM.LoginPolicy.MFA.Unspecified") } multiFactorModel := NewIAMMultiFactorWriteModel(multiFactor) - iamAgg := IAMAggregateFromWriteModel(&multiFactorModel.MultiFactoryWriteModel.WriteModel) + iamAgg := IAMAggregateFromWriteModel(&multiFactorModel.MultiFactorWriteModel.WriteModel) event, err := c.addMultiFactorToDefaultLoginPolicy(ctx, iamAgg, multiFactorModel, multiFactor) if err != nil { return domain.MultiFactorTypeUnspecified, nil, err @@ -235,7 +235,7 @@ func (c *Commands) AddMultiFactorToDefaultLoginPolicy(ctx context.Context, multi if err != nil { return domain.MultiFactorTypeUnspecified, nil, err } - return multiFactorModel.MultiFactoryWriteModel.MFAType, writeModelToObjectDetails(&multiFactorModel.WriteModel), nil + return multiFactorModel.MultiFactorWriteModel.MFAType, writeModelToObjectDetails(&multiFactorModel.WriteModel), nil } func (c *Commands) addMultiFactorToDefaultLoginPolicy(ctx context.Context, iamAgg *eventstore.Aggregate, multiFactorModel *IAMMultiFactorWriteModel, multiFactor domain.MultiFactorType) (eventstore.EventPusher, error) { @@ -262,7 +262,7 @@ func (c *Commands) RemoveMultiFactorFromDefaultLoginPolicy(ctx context.Context, if multiFactorModel.State == domain.FactorStateUnspecified || multiFactorModel.State == domain.FactorStateRemoved { return nil, caos_errs.ThrowNotFound(nil, "IAM-3M9df", "Errors.IAM.LoginPolicy.MFA.NotExisting") } - iamAgg := IAMAggregateFromWriteModel(&multiFactorModel.MultiFactoryWriteModel.WriteModel) + iamAgg := IAMAggregateFromWriteModel(&multiFactorModel.MultiFactorWriteModel.WriteModel) pushedEvents, err := c.eventstore.PushEvents(ctx, iam_repo.NewLoginPolicyMultiFactorRemovedEvent(ctx, iamAgg, multiFactor)) if err != nil { return nil, err diff --git a/internal/command/iam_policy_login_factors_model.go b/internal/command/iam_policy_login_factors_model.go index 561edcd508..711c7a52d2 100644 --- a/internal/command/iam_policy_login_factors_model.go +++ b/internal/command/iam_policy_login_factors_model.go @@ -51,12 +51,12 @@ func (wm *IAMSecondFactorWriteModel) Query() *eventstore.SearchQueryBuilder { } type IAMMultiFactorWriteModel struct { - MultiFactoryWriteModel + MultiFactorWriteModel } func NewIAMMultiFactorWriteModel(factorType domain.MultiFactorType) *IAMMultiFactorWriteModel { return &IAMMultiFactorWriteModel{ - MultiFactoryWriteModel{ + MultiFactorWriteModel{ WriteModel: eventstore.WriteModel{ AggregateID: domain.IAMID, ResourceOwner: domain.IAMID, @@ -82,7 +82,7 @@ func (wm *IAMMultiFactorWriteModel) AppendEvents(events ...eventstore.EventReade } func (wm *IAMMultiFactorWriteModel) Reduce() error { - return wm.MultiFactoryWriteModel.Reduce() + return wm.MultiFactorWriteModel.Reduce() } func (wm *IAMMultiFactorWriteModel) Query() *eventstore.SearchQueryBuilder { diff --git a/internal/command/main_test.go b/internal/command/main_test.go index dfcc3c3f56..050530c968 100644 --- a/internal/command/main_test.go +++ b/internal/command/main_test.go @@ -2,7 +2,14 @@ package command import ( "context" + "testing" + "time" + + "github.com/golang/mock/gomock" + + "github.com/caos/zitadel/internal/api/authz" "github.com/caos/zitadel/internal/crypto" + "github.com/caos/zitadel/internal/errors" "github.com/caos/zitadel/internal/eventstore" "github.com/caos/zitadel/internal/eventstore/repository" "github.com/caos/zitadel/internal/eventstore/repository/mock" @@ -12,9 +19,6 @@ import ( proj_repo "github.com/caos/zitadel/internal/repository/project" usr_repo "github.com/caos/zitadel/internal/repository/user" "github.com/caos/zitadel/internal/repository/usergrant" - "github.com/golang/mock/gomock" - "testing" - "time" ) type expect func(mockRepository *mock.MockRepository) @@ -172,3 +176,48 @@ func GetMockSecretGenerator(t *testing.T) crypto.Generator { return generator } + +func GetMockVerifier(t *testing.T, features ...string) *authz.TokenVerifier { + return authz.Start(&testVerifier{ + features: features, + }) +} + +type testVerifier struct { + features []string +} + +func (v *testVerifier) VerifyAccessToken(ctx context.Context, token, clientID string) (string, string, string, string, error) { + return "userID", "agentID", "de", "orgID", nil +} +func (v *testVerifier) SearchMyMemberships(ctx context.Context) ([]*authz.Membership, error) { + return nil, nil +} + +func (v *testVerifier) ProjectIDAndOriginsByClientID(ctx context.Context, clientID string) (string, []string, error) { + return "", nil, nil +} + +func (v *testVerifier) ExistsOrg(ctx context.Context, orgID string) error { + return nil +} + +func (v *testVerifier) VerifierClientID(ctx context.Context, appName string) (string, error) { + return "clientID", nil +} + +func (v *testVerifier) CheckOrgFeatures(ctx context.Context, orgID string, requiredFeatures ...string) error { + for _, feature := range requiredFeatures { + hasFeature := false + for _, f := range v.features { + if f == feature { + hasFeature = true + break + } + } + if !hasFeature { + return errors.ThrowPermissionDenied(nil, "id", "missing feature") + } + } + return nil +} diff --git a/internal/command/org_features.go b/internal/command/org_features.go new file mode 100644 index 0000000000..900db19cd7 --- /dev/null +++ b/internal/command/org_features.go @@ -0,0 +1,67 @@ +package command + +import ( + "context" + "github.com/caos/zitadel/internal/domain" + caos_errs "github.com/caos/zitadel/internal/errors" + "github.com/caos/zitadel/internal/repository/org" +) + +func (c *Commands) SetOrgFeatures(ctx context.Context, resourceOwner string, features *domain.Features) (*domain.ObjectDetails, error) { + existingFeatures := NewOrgFeaturesWriteModel(resourceOwner) + err := c.eventstore.FilterToQueryReducer(ctx, existingFeatures) + if err != nil { + return nil, err + } + setEvent, hasChanged := existingFeatures.NewSetEvent( + ctx, + OrgAggregateFromWriteModel(&existingFeatures.FeaturesWriteModel.WriteModel), + features.TierName, + features.TierDescription, + features.State, + features.StateDescription, + features.AuditLogRetention, + features.LoginPolicyFactors, + features.LoginPolicyIDP, + features.LoginPolicyPasswordless, + features.LoginPolicyRegistration, + features.LoginPolicyUsernameLogin, + features.PasswordComplexityPolicy, + features.LabelPolicy, + ) + if !hasChanged { + return nil, caos_errs.ThrowPreconditionFailed(nil, "Features-GE4h2", "Errors.Features.NotChanged") + } + + pushedEvents, err := c.eventstore.PushEvents(ctx, setEvent) + if err != nil { + return nil, err + } + err = AppendAndReduce(existingFeatures, pushedEvents...) + if err != nil { + return nil, err + } + return writeModelToObjectDetails(&existingFeatures.WriteModel), nil +} + +func (c *Commands) RemoveOrgFeatures(ctx context.Context, orgID string) (*domain.ObjectDetails, error) { + existingFeatures := NewOrgFeaturesWriteModel(orgID) + err := c.eventstore.FilterToQueryReducer(ctx, existingFeatures) + if err != nil { + return nil, err + } + if existingFeatures.State == domain.FeaturesStateUnspecified || existingFeatures.State == domain.FeaturesStateRemoved { + return nil, caos_errs.ThrowNotFound(nil, "Features-Bg32G", "Errors.Features.NotFound") + } + + orgAgg := OrgAggregateFromWriteModel(&existingFeatures.FeaturesWriteModel.WriteModel) + pushedEvents, err := c.eventstore.PushEvents(ctx, org.NewFeaturesRemovedEvent(ctx, orgAgg)) + if err != nil { + return nil, err + } + err = AppendAndReduce(existingFeatures, pushedEvents...) + if err != nil { + return nil, err + } + return writeModelToObjectDetails(&existingFeatures.WriteModel), nil +} diff --git a/internal/command/org_features_model.go b/internal/command/org_features_model.go new file mode 100644 index 0000000000..037d4dd2cf --- /dev/null +++ b/internal/command/org_features_model.go @@ -0,0 +1,121 @@ +package command + +import ( + "context" + "time" + + "github.com/caos/zitadel/internal/domain" + "github.com/caos/zitadel/internal/eventstore" + "github.com/caos/zitadel/internal/repository/features" + "github.com/caos/zitadel/internal/repository/org" +) + +type OrgFeaturesWriteModel struct { + FeaturesWriteModel +} + +func NewOrgFeaturesWriteModel(orgID string) *OrgFeaturesWriteModel { + return &OrgFeaturesWriteModel{ + FeaturesWriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: orgID, + ResourceOwner: orgID, + }, + }, + } +} + +func (wm *OrgFeaturesWriteModel) AppendEvents(events ...eventstore.EventReader) { + for _, event := range events { + switch e := event.(type) { + case *org.FeaturesSetEvent: + wm.FeaturesWriteModel.AppendEvents(&e.FeaturesSetEvent) + case *org.FeaturesRemovedEvent: + wm.FeaturesWriteModel.AppendEvents(&e.FeaturesRemovedEvent) + } + } +} + +func (wm *OrgFeaturesWriteModel) IsValid() bool { + return wm.AggregateID != "" +} + +func (wm *OrgFeaturesWriteModel) Reduce() error { + return wm.FeaturesWriteModel.Reduce() +} + +func (wm *OrgFeaturesWriteModel) Query() *eventstore.SearchQueryBuilder { + return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent, org.AggregateType). + AggregateIDs(wm.FeaturesWriteModel.AggregateID). + ResourceOwner(wm.ResourceOwner). + EventTypes( + org.FeaturesSetEventType, + org.FeaturesRemovedEventType, + ) +} + +func (wm *OrgFeaturesWriteModel) NewSetEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + tierName, + tierDescription string, + state domain.FeaturesState, + stateDescription string, + auditLogRetention time.Duration, + loginPolicyFactors, + loginPolicyIDP, + loginPolicyPasswordless, + loginPolicyRegistration, + loginPolicyUsernameLogin, + passwordComplexityPolicy, + labelPolicy bool, +) (*org.FeaturesSetEvent, bool) { + + changes := make([]features.FeaturesChanges, 0) + + if tierName != "" && wm.TierName != tierName { + changes = append(changes, features.ChangeTierName(tierName)) + } + if tierDescription != "" && wm.TierDescription != tierDescription { + changes = append(changes, features.ChangeTierDescription(tierDescription)) + } + if wm.State != state { + changes = append(changes, features.ChangeState(state)) + } + if stateDescription != "" && wm.StateDescription != stateDescription { + changes = append(changes, features.ChangeStateDescription(stateDescription)) + } + if auditLogRetention != 0 && wm.AuditLogRetention != auditLogRetention { + changes = append(changes, features.ChangeAuditLogRetention(auditLogRetention)) + } + if wm.LoginPolicyFactors != loginPolicyFactors { + changes = append(changes, features.ChangeLoginPolicyFactors(loginPolicyFactors)) + } + if wm.LoginPolicyIDP != loginPolicyIDP { + changes = append(changes, features.ChangeLoginPolicyIDP(loginPolicyIDP)) + } + if wm.LoginPolicyPasswordless != loginPolicyPasswordless { + changes = append(changes, features.ChangeLoginPolicyPasswordless(loginPolicyPasswordless)) + } + if wm.LoginPolicyRegistration != loginPolicyRegistration { + changes = append(changes, features.ChangeLoginPolicyRegistration(loginPolicyRegistration)) + } + if wm.LoginPolicyUsernameLogin != loginPolicyUsernameLogin { + changes = append(changes, features.ChangeLoginPolicyUsernameLogin(loginPolicyUsernameLogin)) + } + if wm.PasswordComplexityPolicy != passwordComplexityPolicy { + changes = append(changes, features.ChangePasswordComplexityPolicy(passwordComplexityPolicy)) + } + if wm.LabelPolicy != labelPolicy { + changes = append(changes, features.ChangeLabelPolicy(labelPolicy)) + } + + if len(changes) == 0 { + return nil, false + } + changedEvent, err := org.NewFeaturesSetEvent(ctx, aggregate, changes) + if err != nil { + return nil, false + } + return changedEvent, true +} diff --git a/internal/command/org_policy_login.go b/internal/command/org_policy_login.go index f0eab94d16..3663127b67 100644 --- a/internal/command/org_policy_login.go +++ b/internal/command/org_policy_login.go @@ -2,7 +2,11 @@ package command import ( "context" + "reflect" + "github.com/caos/logging" + + "github.com/caos/zitadel/internal/api/authz" "github.com/caos/zitadel/internal/domain" caos_errs "github.com/caos/zitadel/internal/errors" "github.com/caos/zitadel/internal/eventstore" @@ -22,6 +26,11 @@ func (c *Commands) AddLoginPolicy(ctx context.Context, resourceOwner string, pol return nil, caos_errs.ThrowAlreadyExists(nil, "Org-Dgfb2", "Errors.Org.LoginPolicy.AlreadyExists") } + err = c.checkLoginPolicyAllowed(ctx, resourceOwner, policy) + if err != nil { + return nil, err + } + orgAgg := OrgAggregateFromWriteModel(&addedPolicy.WriteModel) pushedEvents, err := c.eventstore.PushEvents( ctx, @@ -43,6 +52,15 @@ func (c *Commands) AddLoginPolicy(ctx context.Context, resourceOwner string, pol return writeModelToLoginPolicy(&addedPolicy.LoginPolicyWriteModel), nil } +func (c *Commands) orgLoginPolicyWriteModelByID(ctx context.Context, orgID string) (*OrgLoginPolicyWriteModel, error) { + policyWriteModel := NewOrgLoginPolicyWriteModel(orgID) + err := c.eventstore.FilterToQueryReducer(ctx, policyWriteModel) + if err != nil { + return nil, err + } + return policyWriteModel, nil +} + func (c *Commands) ChangeLoginPolicy(ctx context.Context, resourceOwner string, policy *domain.LoginPolicy) (*domain.LoginPolicy, error) { if resourceOwner == "" { return nil, caos_errs.ThrowInvalidArgument(nil, "Org-Mf9sf", "Errors.ResourceOwnerMissing") @@ -55,6 +73,12 @@ func (c *Commands) ChangeLoginPolicy(ctx context.Context, resourceOwner string, if existingPolicy.State == domain.PolicyStateUnspecified || existingPolicy.State == domain.PolicyStateRemoved { return nil, caos_errs.ThrowNotFound(nil, "Org-M0sif", "Errors.Org.LoginPolicy.NotFound") } + + err = c.checkLoginPolicyAllowed(ctx, resourceOwner, policy) + if err != nil { + return nil, err + } + orgAgg := OrgAggregateFromWriteModel(&existingPolicy.LoginPolicyWriteModel.WriteModel) changedEvent, hasChanged := existingPolicy.NewChangedEvent(ctx, orgAgg, policy.AllowUsernamePassword, policy.AllowRegister, policy.AllowExternalIDP, policy.ForceMFA, policy.PasswordlessType) if !hasChanged { @@ -72,6 +96,30 @@ func (c *Commands) ChangeLoginPolicy(ctx context.Context, resourceOwner string, return writeModelToLoginPolicy(&existingPolicy.LoginPolicyWriteModel), nil } +func (c *Commands) checkLoginPolicyAllowed(ctx context.Context, resourceOwner string, policy *domain.LoginPolicy) error { + defaultPolicy, err := c.getDefaultLoginPolicy(ctx) + if err != nil { + return err + } + requiredFeatures := make([]string, 0) + if defaultPolicy.ForceMFA != policy.ForceMFA || !reflect.DeepEqual(defaultPolicy.MultiFactors, policy.MultiFactors) || !reflect.DeepEqual(defaultPolicy.SecondFactors, policy.SecondFactors) { + requiredFeatures = append(requiredFeatures, domain.FeatureLoginPolicyFactors) + } + if defaultPolicy.AllowExternalIDP != policy.AllowExternalIDP || !reflect.DeepEqual(defaultPolicy.IDPProviders, policy.IDPProviders) { + requiredFeatures = append(requiredFeatures, domain.FeatureLoginPolicyIDP) + } + if defaultPolicy.AllowRegister != policy.AllowRegister { + requiredFeatures = append(requiredFeatures, domain.FeatureLoginPolicyRegistration) + } + if defaultPolicy.PasswordlessType != policy.PasswordlessType { + requiredFeatures = append(requiredFeatures, domain.FeatureLoginPolicyPasswordless) + } + if defaultPolicy.AllowUsernamePassword != policy.AllowUsernamePassword { + requiredFeatures = append(requiredFeatures, domain.FeatureLoginPolicyUsernameLogin) + } + return authz.CheckOrgFeatures(ctx, c.tokenVerifier, resourceOwner, requiredFeatures...) +} + func (c *Commands) RemoveLoginPolicy(ctx context.Context, orgID string) (*domain.ObjectDetails, error) { if orgID == "" { return nil, caos_errs.ThrowInvalidArgument(nil, "Org-55Mg9", "Errors.ResourceOwnerMissing") @@ -267,7 +315,7 @@ func (c *Commands) AddMultiFactorToLoginPolicy(ctx context.Context, multiFactor if err != nil { return domain.MultiFactorTypeUnspecified, nil, err } - return multiFactorModel.MultiFactoryWriteModel.MFAType, writeModelToObjectDetails(&multiFactorModel.WriteModel), nil + return multiFactorModel.MultiFactorWriteModel.MFAType, writeModelToObjectDetails(&multiFactorModel.WriteModel), nil } func (c *Commands) RemoveMultiFactorFromLoginPolicy(ctx context.Context, multiFactor domain.MultiFactorType, orgID string) (*domain.ObjectDetails, error) { @@ -285,7 +333,7 @@ func (c *Commands) RemoveMultiFactorFromLoginPolicy(ctx context.Context, multiFa if multiFactorModel.State == domain.FactorStateUnspecified || multiFactorModel.State == domain.FactorStateRemoved { return nil, caos_errs.ThrowNotFound(nil, "Org-3M9df", "Errors.Org.LoginPolicy.MFA.NotExisting") } - orgAgg := OrgAggregateFromWriteModel(&multiFactorModel.MultiFactoryWriteModel.WriteModel) + orgAgg := OrgAggregateFromWriteModel(&multiFactorModel.MultiFactorWriteModel.WriteModel) pushedEvents, err := c.eventstore.PushEvents(ctx, org.NewLoginPolicyMultiFactorRemovedEvent(ctx, orgAgg, multiFactor)) if err != nil { diff --git a/internal/command/org_policy_login_factors_model.go b/internal/command/org_policy_login_factors_model.go index d54540b688..4d432e59ca 100644 --- a/internal/command/org_policy_login_factors_model.go +++ b/internal/command/org_policy_login_factors_model.go @@ -51,12 +51,12 @@ func (wm *OrgSecondFactorWriteModel) Query() *eventstore.SearchQueryBuilder { } type OrgMultiFactorWriteModel struct { - MultiFactoryWriteModel + MultiFactorWriteModel } func NewOrgMultiFactorWriteModel(orgID string, factorType domain.MultiFactorType) *OrgMultiFactorWriteModel { return &OrgMultiFactorWriteModel{ - MultiFactoryWriteModel{ + MultiFactorWriteModel{ WriteModel: eventstore.WriteModel{ AggregateID: orgID, ResourceOwner: orgID, @@ -82,7 +82,7 @@ func (wm *OrgMultiFactorWriteModel) AppendEvents(events ...eventstore.EventReade } func (wm *OrgMultiFactorWriteModel) Reduce() error { - return wm.MultiFactoryWriteModel.Reduce() + return wm.MultiFactorWriteModel.Reduce() } func (wm *OrgMultiFactorWriteModel) Query() *eventstore.SearchQueryBuilder { diff --git a/internal/command/org_policy_login_test.go b/internal/command/org_policy_login_test.go index d935a4e6e1..e78501ee9a 100644 --- a/internal/command/org_policy_login_test.go +++ b/internal/command/org_policy_login_test.go @@ -6,11 +6,13 @@ import ( "github.com/stretchr/testify/assert" + "github.com/caos/zitadel/internal/api/authz" "github.com/caos/zitadel/internal/domain" caos_errs "github.com/caos/zitadel/internal/errors" "github.com/caos/zitadel/internal/eventstore" "github.com/caos/zitadel/internal/eventstore/repository" "github.com/caos/zitadel/internal/eventstore/v1/models" + "github.com/caos/zitadel/internal/repository/iam" "github.com/caos/zitadel/internal/repository/org" "github.com/caos/zitadel/internal/repository/policy" "github.com/caos/zitadel/internal/repository/user" @@ -18,7 +20,8 @@ import ( func TestCommandSide_AddLoginPolicy(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore *eventstore.Eventstore + tokenVerifier *authz.TokenVerifier } type args struct { ctx context.Context @@ -88,12 +91,60 @@ func TestCommandSide_AddLoginPolicy(t *testing.T) { err: caos_errs.IsErrorAlreadyExists, }, }, + { + name: "loginpolicy not allowed, permission denied error", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter(), + expectFilter( + eventFromEventPusher( + iam.NewLoginPolicyAddedEvent(context.Background(), + &iam.NewAggregate().Aggregate, + false, + true, + true, + true, + domain.PasswordlessTypeAllowed, + ), + ), + ), + ), + tokenVerifier: GetMockVerifier(t), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + policy: &domain.LoginPolicy{ + AllowRegister: true, + AllowUsernamePassword: true, + AllowExternalIDP: true, + ForceMFA: true, + PasswordlessType: domain.PasswordlessTypeAllowed, + }, + }, + res: res{ + err: caos_errs.IsPermissionDenied, + }, + }, { name: "add policy,ok", fields: fields{ eventstore: eventstoreExpect( t, expectFilter(), + expectFilter( + eventFromEventPusher( + iam.NewLoginPolicyAddedEvent(context.Background(), + &iam.NewAggregate().Aggregate, + false, + true, + true, + true, + domain.PasswordlessTypeAllowed, + ), + ), + ), expectPush( []*repository.Event{ eventFromEventPusher( @@ -109,6 +160,7 @@ func TestCommandSide_AddLoginPolicy(t *testing.T) { }, ), ), + tokenVerifier: GetMockVerifier(t, domain.FeatureLoginPolicyUsernameLogin), }, args: args{ ctx: context.Background(), @@ -139,7 +191,8 @@ func TestCommandSide_AddLoginPolicy(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore, + tokenVerifier: tt.fields.tokenVerifier, } got, err := r.AddLoginPolicy(tt.args.ctx, tt.args.orgID, tt.args.policy) if tt.res.err == nil { @@ -157,7 +210,8 @@ func TestCommandSide_AddLoginPolicy(t *testing.T) { func TestCommandSide_ChangeLoginPolicy(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore *eventstore.Eventstore + tokenVerifier *authz.TokenVerifier } type args struct { ctx context.Context @@ -218,6 +272,53 @@ func TestCommandSide_ChangeLoginPolicy(t *testing.T) { err: caos_errs.IsNotFound, }, }, + { + name: "not allowed, permission denied error", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + org.NewLoginPolicyAddedEvent(context.Background(), + &org.NewAggregate("org1", "org1").Aggregate, + true, + true, + true, + true, + domain.PasswordlessTypeAllowed, + ), + ), + ), + expectFilter( + eventFromEventPusher( + iam.NewLoginPolicyAddedEvent(context.Background(), + &iam.NewAggregate().Aggregate, + false, + true, + true, + true, + domain.PasswordlessTypeAllowed, + ), + ), + ), + ), + tokenVerifier: GetMockVerifier(t), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + policy: &domain.LoginPolicy{ + AllowRegister: true, + AllowUsernamePassword: true, + AllowExternalIDP: true, + ForceMFA: true, + PasswordlessType: domain.PasswordlessTypeAllowed, + }, + }, + res: res{ + err: caos_errs.IsPermissionDenied, + }, + }, { name: "no changes, precondition error", fields: fields{ @@ -235,7 +336,20 @@ func TestCommandSide_ChangeLoginPolicy(t *testing.T) { ), ), ), + expectFilter( + eventFromEventPusher( + iam.NewLoginPolicyAddedEvent(context.Background(), + &iam.NewAggregate().Aggregate, + false, + true, + true, + true, + domain.PasswordlessTypeAllowed, + ), + ), + ), ), + tokenVerifier: GetMockVerifier(t, domain.FeatureLoginPolicyUsernameLogin), }, args: args{ ctx: context.Background(), @@ -269,6 +383,18 @@ func TestCommandSide_ChangeLoginPolicy(t *testing.T) { ), ), ), + expectFilter( + eventFromEventPusher( + iam.NewLoginPolicyAddedEvent(context.Background(), + &iam.NewAggregate().Aggregate, + false, + false, + false, + false, + domain.PasswordlessTypeNotAllowed, + ), + ), + ), expectPush( []*repository.Event{ eventFromEventPusher( @@ -277,6 +403,7 @@ func TestCommandSide_ChangeLoginPolicy(t *testing.T) { }, ), ), + tokenVerifier: GetMockVerifier(t, domain.FeatureLoginPolicyUsernameLogin), }, args: args{ ctx: context.Background(), @@ -307,7 +434,8 @@ func TestCommandSide_ChangeLoginPolicy(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore, + tokenVerifier: tt.fields.tokenVerifier, } got, err := r.ChangeLoginPolicy(tt.args.ctx, tt.args.orgID, tt.args.policy) if tt.res.err == nil { diff --git a/internal/command/policy_login_factors_model.go b/internal/command/policy_login_factors_model.go index 9ef21afcd3..58b31d2c31 100644 --- a/internal/command/policy_login_factors_model.go +++ b/internal/command/policy_login_factors_model.go @@ -26,13 +26,13 @@ func (wm *SecondFactorWriteModel) Reduce() error { return wm.WriteModel.Reduce() } -type MultiFactoryWriteModel struct { +type MultiFactorWriteModel struct { eventstore.WriteModel MFAType domain.MultiFactorType State domain.FactorState } -func (wm *MultiFactoryWriteModel) Reduce() error { +func (wm *MultiFactorWriteModel) Reduce() error { for _, event := range wm.Events { switch e := event.(type) { case *policy.MultiFactorAddedEvent: diff --git a/internal/command/setup_step12.go b/internal/command/setup_step12.go new file mode 100644 index 0000000000..0d79e18854 --- /dev/null +++ b/internal/command/setup_step12.go @@ -0,0 +1,52 @@ +package command + +import ( + "context" + + "github.com/caos/zitadel/internal/config/types" + "github.com/caos/zitadel/internal/domain" + "github.com/caos/zitadel/internal/eventstore" +) + +type Step12 struct { + TierName string + TierDescription string + AuditLogRetention types.Duration + LoginPolicyFactors bool + LoginPolicyIDP bool + LoginPolicyPasswordless bool + LoginPolicyRegistration bool + LoginPolicyUsernameLogin bool + PasswordComplexityPolicy bool +} + +func (s *Step12) Step() domain.Step { + return domain.Step12 +} + +func (s *Step12) execute(ctx context.Context, commandSide *Commands) error { + return commandSide.SetupStep12(ctx, s) +} + +func (c *Commands) SetupStep12(ctx context.Context, step *Step12) error { + fn := func(iam *IAMWriteModel) ([]eventstore.EventPusher, error) { + featuresWriteModel := NewIAMFeaturesWriteModel() + featuresEvent, err := c.setDefaultFeatures(ctx, featuresWriteModel, &domain.Features{ + TierName: step.TierName, + TierDescription: step.TierDescription, + State: domain.FeaturesStateActive, + AuditLogRetention: step.AuditLogRetention.Duration, + LoginPolicyFactors: step.LoginPolicyFactors, + LoginPolicyIDP: step.LoginPolicyIDP, + LoginPolicyPasswordless: step.LoginPolicyPasswordless, + LoginPolicyRegistration: step.LoginPolicyRegistration, + LoginPolicyUsernameLogin: step.LoginPolicyUsernameLogin, + PasswordComplexityPolicy: step.PasswordComplexityPolicy, + }) + if err != nil { + return nil, err + } + return []eventstore.EventPusher{featuresEvent}, nil + } + return c.setup(ctx, step, fn) +} diff --git a/internal/command/setup_step9.go b/internal/command/setup_step9.go index c94e4355b2..03316a8a94 100644 --- a/internal/command/setup_step9.go +++ b/internal/command/setup_step9.go @@ -23,7 +23,7 @@ func (s *Step9) execute(ctx context.Context, commandSide *Commands) error { func (c *Commands) SetupStep9(ctx context.Context, step *Step9) error { fn := func(iam *IAMWriteModel) ([]eventstore.EventPusher, error) { multiFactorModel := NewIAMMultiFactorWriteModel(domain.MultiFactorTypeU2FWithPIN) - iamAgg := IAMAggregateFromWriteModel(&multiFactorModel.MultiFactoryWriteModel.WriteModel) + iamAgg := IAMAggregateFromWriteModel(&multiFactorModel.MultiFactorWriteModel.WriteModel) if !step.Passwordless { return []eventstore.EventPusher{}, nil } diff --git a/internal/domain/features.go b/internal/domain/features.go new file mode 100644 index 0000000000..50ea6a09cc --- /dev/null +++ b/internal/domain/features.go @@ -0,0 +1,54 @@ +package domain + +import ( + "time" + + es_models "github.com/caos/zitadel/internal/eventstore/v1/models" +) + +const ( + FeatureLoginPolicy = "login_policy" + FeatureLoginPolicyFactors = FeatureLoginPolicy + ".factors" + FeatureLoginPolicyIDP = FeatureLoginPolicy + ".idp" + FeatureLoginPolicyPasswordless = FeatureLoginPolicy + ".passwordless" + FeatureLoginPolicyRegistration = FeatureLoginPolicy + ".registration" + FeatureLoginPolicyUsernameLogin = FeatureLoginPolicy + ".username_login" + FeaturePasswordComplexityPolicy = "password_complexity_policy" + FeatureLabelPolicy = "label_policy" +) + +type Features struct { + es_models.ObjectRoot + + TierName string + TierDescription string + State FeaturesState + StateDescription string + IsDefault bool + + AuditLogRetention time.Duration + LoginPolicyFactors bool + LoginPolicyIDP bool + LoginPolicyPasswordless bool + LoginPolicyRegistration bool + LoginPolicyUsernameLogin bool + PasswordComplexityPolicy bool + LabelPolicy bool +} + +type FeaturesState int32 + +const ( + FeaturesStateUnspecified FeaturesState = iota + FeaturesStateActive + FeaturesStateActionRequired + FeaturesStateCanceled + FeaturesStateGrandfathered + FeaturesStateRemoved + + featuresStateCount +) + +func (f FeaturesState) Valid() bool { + return f >= 0 && f < featuresStateCount +} diff --git a/internal/domain/roles.go b/internal/domain/roles.go index c4dec8e7cc..ade3a7feec 100644 --- a/internal/domain/roles.go +++ b/internal/domain/roles.go @@ -1,8 +1,9 @@ package domain import ( - "github.com/caos/zitadel/internal/api/authz" "strings" + + "github.com/caos/zitadel/internal/api/authz" ) const ( diff --git a/internal/domain/step.go b/internal/domain/step.go index 05a6d884ff..1fa3de5504 100644 --- a/internal/domain/step.go +++ b/internal/domain/step.go @@ -14,6 +14,7 @@ const ( Step9 Step10 Step11 + Step12 //StepCount marks the the length of possible steps (StepCount-1 == last possible step) StepCount ) diff --git a/internal/eventstore/v1/internal/repository/sql/query.go b/internal/eventstore/v1/internal/repository/sql/query.go index e87f7a7ce0..4eecd11060 100644 --- a/internal/eventstore/v1/internal/repository/sql/query.go +++ b/internal/eventstore/v1/internal/repository/sql/query.go @@ -181,6 +181,8 @@ func getField(field es_models.Field) string { return "editor_user" case es_models.Field_EventType: return "event_type" + case es_models.Field_CreationDate: + return "creation_date" } return "" } diff --git a/internal/eventstore/v1/models/field.go b/internal/eventstore/v1/models/field.go index 72ce5831ff..a14057faf7 100644 --- a/internal/eventstore/v1/models/field.go +++ b/internal/eventstore/v1/models/field.go @@ -10,4 +10,5 @@ const ( Field_EditorService Field_EditorUser Field_EventType + Field_CreationDate ) diff --git a/internal/eventstore/v1/models/search_query.go b/internal/eventstore/v1/models/search_query.go index 06cbf97f1d..232a17b3ba 100644 --- a/internal/eventstore/v1/models/search_query.go +++ b/internal/eventstore/v1/models/search_query.go @@ -1,6 +1,8 @@ package models import ( + "time" + "github.com/caos/logging" "github.com/caos/zitadel/internal/errors" ) @@ -15,6 +17,7 @@ type SearchQueryFactory struct { sequenceTo uint64 eventTypes []EventType resourceOwner string + creationDate time.Time } type searchQuery struct { @@ -63,7 +66,8 @@ func FactoryFromSearchQuery(query *SearchQuery) *SearchQueryFactory { factory = factory.EventTypes(filter.value.([]EventType)...) case Field_EditorService, Field_EditorUser: logging.Log("MODEL-Mr0VN").WithField("value", filter.value).Panic("field not converted to factory") - + case Field_CreationDate: + factory = factory.CreationDateNewer(filter.value.(time.Time)) } } @@ -116,6 +120,11 @@ func (factory *SearchQueryFactory) ResourceOwner(resourceOwner string) *SearchQu return factory } +func (factory *SearchQueryFactory) CreationDateNewer(time time.Time) *SearchQueryFactory { + factory.creationDate = time + return factory +} + func (factory *SearchQueryFactory) OrderDesc() *SearchQueryFactory { factory.desc = true return factory @@ -142,6 +151,7 @@ func (factory *SearchQueryFactory) Build() (*searchQuery, error) { factory.sequenceToFilter, factory.eventTypeFilter, factory.resourceOwnerFilter, + factory.creationDateNewerFilter, } { if filter := f(); filter != nil { filters = append(filters, filter) @@ -211,3 +221,10 @@ func (factory *SearchQueryFactory) resourceOwnerFilter() *Filter { } return NewFilter(Field_ResourceOwner, factory.resourceOwner, Operation_Equals) } + +func (factory *SearchQueryFactory) creationDateNewerFilter() *Filter { + if factory.creationDate.IsZero() { + return nil + } + return NewFilter(Field_CreationDate, factory.creationDate, Operation_Greater) +} diff --git a/internal/eventstore/v1/models/search_query_old.go b/internal/eventstore/v1/models/search_query_old.go index 1748b9d4d7..b68947556d 100644 --- a/internal/eventstore/v1/models/search_query_old.go +++ b/internal/eventstore/v1/models/search_query_old.go @@ -1,6 +1,10 @@ package models -import "github.com/caos/zitadel/internal/errors" +import ( + "time" + + "github.com/caos/zitadel/internal/errors" +) //SearchQuery is deprecated. Use SearchQueryFactory type SearchQuery struct { @@ -68,6 +72,10 @@ func (q *SearchQuery) ResourceOwnerFilter(resourceOwner string) *SearchQuery { return q.setFilter(NewFilter(Field_ResourceOwner, resourceOwner, Operation_Equals)) } +func (q *SearchQuery) CreationDateNewerFilter(time time.Time) *SearchQuery { + return q.setFilter(NewFilter(Field_CreationDate, time, Operation_Greater)) +} + func (q *SearchQuery) setFilter(filter *Filter) *SearchQuery { for i, f := range q.Filters { if f.field == filter.field && f.field != Field_LatestSequence { diff --git a/internal/features/model/features_view.go b/internal/features/model/features_view.go new file mode 100644 index 0000000000..c6787ef9a5 --- /dev/null +++ b/internal/features/model/features_view.go @@ -0,0 +1,85 @@ +package model + +import ( + "time" + + "github.com/caos/zitadel/internal/domain" +) + +type FeaturesView struct { + AggregateID string + CreationDate time.Time + ChangeDate time.Time + Sequence uint64 + Default bool + + TierName string + TierDescription string + State domain.FeaturesState + StateDescription string + AuditLogRetention time.Duration + LoginPolicyFactors bool + LoginPolicyIDP bool + LoginPolicyPasswordless bool + LoginPolicyRegistration bool + LoginPolicyUsernameLogin bool + PasswordComplexityPolicy bool + LabelPolicy bool +} + +func (f *FeaturesView) FeatureList() []string { + list := make([]string, 0, 6) + if f.LoginPolicyFactors { + list = append(list, domain.FeatureLoginPolicyFactors) + } + if f.LoginPolicyIDP { + list = append(list, domain.FeatureLoginPolicyIDP) + } + if f.LoginPolicyPasswordless { + list = append(list, domain.FeatureLoginPolicyPasswordless) + } + if f.LoginPolicyRegistration { + list = append(list, domain.FeatureLoginPolicyRegistration) + } + if f.LoginPolicyUsernameLogin { + list = append(list, domain.FeatureLoginPolicyUsernameLogin) + } + if f.PasswordComplexityPolicy { + list = append(list, domain.FeaturePasswordComplexityPolicy) + } + if f.LabelPolicy { + list = append(list, domain.FeatureLabelPolicy) + } + return list +} + +type FeaturesSearchRequest struct { + Offset uint64 + Limit uint64 + SortingColumn FeaturesSearchKey + Asc bool + Queries []*FeaturesSearchQuery +} + +type FeaturesSearchKey int32 + +const ( + FeaturesSearchKeyUnspecified FeaturesSearchKey = iota + FeaturesSearchKeyAggregateID + FeaturesSearchKeyDefault +) + +type FeaturesSearchQuery struct { + Key FeaturesSearchKey + Method domain.SearchMethod + Value interface{} +} + +type FeaturesSearchResult struct { + Offset uint64 + Limit uint64 + TotalResult uint64 + Result []*FeaturesView + Sequence uint64 + Timestamp time.Time +} diff --git a/internal/features/repository/view/features_view.go b/internal/features/repository/view/features_view.go new file mode 100644 index 0000000000..fdd6ae29f0 --- /dev/null +++ b/internal/features/repository/view/features_view.go @@ -0,0 +1,48 @@ +package view + +import ( + "github.com/jinzhu/gorm" + + "github.com/caos/zitadel/internal/domain" + caos_errs "github.com/caos/zitadel/internal/errors" + "github.com/caos/zitadel/internal/features/model" + view_model "github.com/caos/zitadel/internal/features/repository/view/model" + "github.com/caos/zitadel/internal/view/repository" +) + +func GetDefaultFeatures(db *gorm.DB, table string) ([]*view_model.FeaturesView, error) { + features := make([]*view_model.FeaturesView, 0) + queries := []*model.FeaturesSearchQuery{ + {Key: model.FeaturesSearchKeyDefault, Value: true, Method: domain.SearchMethodEquals}, + } + query := repository.PrepareSearchQuery(table, view_model.FeaturesSearchRequest{Queries: queries}) + _, err := query(db, &features) + if err != nil { + return nil, err + } + return features, nil +} + +func GetFeaturesByAggregateID(db *gorm.DB, table, aggregateID string) (*view_model.FeaturesView, error) { + features := new(view_model.FeaturesView) + query := repository.PrepareGetByKey(table, view_model.FeaturesSearchKey(model.FeaturesSearchKeyAggregateID), aggregateID) + err := query(db, features) + if caos_errs.IsNotFound(err) { + return nil, caos_errs.ThrowNotFound(nil, "VIEW-Dbf3h", "Errors.Features.NotFound") + } + return features, err +} + +func PutFeatures(db *gorm.DB, table string, features *view_model.FeaturesView) error { + save := repository.PrepareSave(table) + return save(db, features) +} + +func PutFeaturesList(db *gorm.DB, table string, featuresList ...*view_model.FeaturesView) error { + save := repository.PrepareBulkSave(table) + f := make([]interface{}, len(featuresList)) + for i, features := range featuresList { + f[i] = features + } + return save(db, f...) +} diff --git a/internal/features/repository/view/model/features.go b/internal/features/repository/view/model/features.go new file mode 100644 index 0000000000..98968b2079 --- /dev/null +++ b/internal/features/repository/view/model/features.go @@ -0,0 +1,95 @@ +package model + +import ( + "encoding/json" + "time" + + "github.com/caos/logging" + + "github.com/caos/zitadel/internal/domain" + caos_errs "github.com/caos/zitadel/internal/errors" + "github.com/caos/zitadel/internal/eventstore/v1/models" + features_model "github.com/caos/zitadel/internal/features/model" + iam_repo "github.com/caos/zitadel/internal/repository/iam" + org_repo "github.com/caos/zitadel/internal/repository/org" +) + +const ( + FeaturesKeyAggregateID = "aggregate_id" + FeaturesKeyDefault = "default_features" +) + +type FeaturesView struct { + AggregateID string `json:"-" gorm:"column:aggregate_id;primary_key"` + CreationDate time.Time `json:"-" gorm:"column:creation_date"` + ChangeDate time.Time `json:"-" gorm:"column:change_date"` + Sequence uint64 `json:"-" gorm:"column:sequence"` + Default bool `json:"-" gorm:"column:default_features"` + + TierName string `json:"tierName" gorm:"column:tier_name"` + TierDescription string `json:"tierDescription" gorm:"column:tier_description"` + State int32 `json:"state" gorm:"column:state"` + StateDescription string `json:"stateDescription" gorm:"column:state_description"` + AuditLogRetention time.Duration `json:"auditLogRetention" gorm:"column:audit_log_retention"` + LoginPolicyFactors bool `json:"loginPolicyFactors" gorm:"column:login_policy_factors"` + LoginPolicyIDP bool `json:"loginPolicyIDP" gorm:"column:login_policy_idp"` + LoginPolicyPasswordless bool `json:"loginPolicyPasswordless" gorm:"column:login_policy_passwordless"` + LoginPolicyRegistration bool `json:"loginPolicyRegistration" gorm:"column:login_policy_registration"` + LoginPolicyUsernameLogin bool `json:"loginPolicyUsernameLogin" gorm:"column:login_policy_username_login"` + PasswordComplexityPolicy bool `json:"passwordComplexityPolicy" gorm:"column:password_complexity_policy"` + LabelPolicy bool `json:"labelPolicy" gorm:"column:label_policy"` +} + +func FeaturesToModel(features *FeaturesView) *features_model.FeaturesView { + return &features_model.FeaturesView{ + AggregateID: features.AggregateID, + CreationDate: features.CreationDate, + ChangeDate: features.ChangeDate, + Sequence: features.Sequence, + Default: features.Default, + TierName: features.TierName, + TierDescription: features.TierDescription, + State: domain.FeaturesState(features.State), + StateDescription: features.StateDescription, + AuditLogRetention: features.AuditLogRetention, + LoginPolicyFactors: features.LoginPolicyFactors, + LoginPolicyIDP: features.LoginPolicyIDP, + LoginPolicyPasswordless: features.LoginPolicyPasswordless, + LoginPolicyRegistration: features.LoginPolicyRegistration, + LoginPolicyUsernameLogin: features.LoginPolicyUsernameLogin, + PasswordComplexityPolicy: features.PasswordComplexityPolicy, + LabelPolicy: features.LabelPolicy, + } +} + +func (f *FeaturesView) AppendEvent(event *models.Event) (err error) { + f.Sequence = event.Sequence + f.ChangeDate = event.CreationDate + switch string(event.Type) { + case string(iam_repo.FeaturesSetEventType): + f.SetRootData(event) + f.CreationDate = event.CreationDate + f.Default = true + err = f.SetData(event) + case string(org_repo.FeaturesSetEventType): + f.SetRootData(event) + f.CreationDate = event.CreationDate + err = f.SetData(event) + f.Default = false + } + return err +} + +func (f *FeaturesView) SetRootData(event *models.Event) { + if f.AggregateID == "" { + f.AggregateID = event.AggregateID + } +} + +func (f *FeaturesView) SetData(event *models.Event) error { + if err := json.Unmarshal(event.Data, f); err != nil { + logging.Log("EVEN-DVsf2").WithError(err).Error("could not unmarshal event data") + return caos_errs.ThrowInternal(err, "MODEL-Bfg31", "Could not unmarshal data") + } + return nil +} diff --git a/internal/features/repository/view/model/features_query.go b/internal/features/repository/view/model/features_query.go new file mode 100644 index 0000000000..c5035827ab --- /dev/null +++ b/internal/features/repository/view/model/features_query.go @@ -0,0 +1,61 @@ +package model + +import ( + "github.com/caos/zitadel/internal/domain" + "github.com/caos/zitadel/internal/features/model" + "github.com/caos/zitadel/internal/view/repository" +) + +type FeaturesSearchRequest model.FeaturesSearchRequest +type FeaturesSearchQuery model.FeaturesSearchQuery +type FeaturesSearchKey model.FeaturesSearchKey + +func (req FeaturesSearchRequest) GetLimit() uint64 { + return req.Limit +} + +func (req FeaturesSearchRequest) GetOffset() uint64 { + return req.Offset +} + +func (req FeaturesSearchRequest) GetSortingColumn() repository.ColumnKey { + if req.SortingColumn == model.FeaturesSearchKeyUnspecified { + return nil + } + return FeaturesSearchKey(req.SortingColumn) +} + +func (req FeaturesSearchRequest) GetAsc() bool { + return req.Asc +} + +func (req FeaturesSearchRequest) GetQueries() []repository.SearchQuery { + result := make([]repository.SearchQuery, len(req.Queries)) + for i, q := range req.Queries { + result[i] = FeaturesSearchQuery{Key: q.Key, Value: q.Value, Method: q.Method} + } + return result +} + +func (req FeaturesSearchQuery) GetKey() repository.ColumnKey { + return FeaturesSearchKey(req.Key) +} + +func (req FeaturesSearchQuery) GetMethod() domain.SearchMethod { + return req.Method +} + +func (req FeaturesSearchQuery) GetValue() interface{} { + return req.Value +} + +func (key FeaturesSearchKey) ToColumnName() string { + switch model.FeaturesSearchKey(key) { + case model.FeaturesSearchKeyAggregateID: + return FeaturesKeyAggregateID + case model.FeaturesSearchKeyDefault: + return FeaturesKeyDefault + default: + return "" + } +} diff --git a/internal/management/repository/eventsourcing/eventstore/features.go b/internal/management/repository/eventsourcing/eventstore/features.go new file mode 100644 index 0000000000..07705e48c5 --- /dev/null +++ b/internal/management/repository/eventsourcing/eventstore/features.go @@ -0,0 +1,70 @@ +package eventstore + +import ( + "context" + + "github.com/caos/logging" + + "github.com/caos/zitadel/internal/config/systemdefaults" + "github.com/caos/zitadel/internal/domain" + "github.com/caos/zitadel/internal/errors" + v1 "github.com/caos/zitadel/internal/eventstore/v1" + "github.com/caos/zitadel/internal/eventstore/v1/models" + features_model "github.com/caos/zitadel/internal/features/model" + "github.com/caos/zitadel/internal/features/repository/view/model" + iam_view "github.com/caos/zitadel/internal/iam/repository/view" + mgmt_view "github.com/caos/zitadel/internal/management/repository/eventsourcing/view" +) + +type FeaturesRepo struct { + Eventstore v1.Eventstore + + View *mgmt_view.View + + SearchLimit uint64 + SystemDefaults systemdefaults.SystemDefaults +} + +func (repo *FeaturesRepo) GetDefaultFeatures(ctx context.Context) (*features_model.FeaturesView, error) { + features, viewErr := repo.View.FeaturesByAggregateID(domain.IAMID) + if viewErr != nil && !errors.IsNotFound(viewErr) { + return nil, viewErr + } + if errors.IsNotFound(viewErr) { + features = new(model.FeaturesView) + } + events, esErr := repo.getIAMEvents(ctx, features.Sequence) + if errors.IsNotFound(viewErr) && len(events) == 0 { + return nil, errors.ThrowNotFound(nil, "EVENT-Lsoj7", "Errors.Org.NotFound") + } + if esErr != nil { + logging.Log("EVENT-PSoc3").WithError(esErr).Debug("error retrieving new events") + return model.FeaturesToModel(features), nil + } + featuresCopy := *features + for _, event := range events { + if err := featuresCopy.AppendEvent(event); err != nil { + return model.FeaturesToModel(&featuresCopy), nil + } + } + return model.FeaturesToModel(&featuresCopy), nil +} + +func (repo *FeaturesRepo) GetOrgFeatures(ctx context.Context, orgID string) (*features_model.FeaturesView, error) { + features, err := repo.View.FeaturesByAggregateID(orgID) + if errors.IsNotFound(err) { + return repo.GetDefaultFeatures(ctx) + } + if err != nil { + return nil, err + } + return model.FeaturesToModel(features), err +} + +func (repo *FeaturesRepo) getIAMEvents(ctx context.Context, sequence uint64) ([]*models.Event, error) { + query, err := iam_view.IAMByIDQuery(domain.IAMID, sequence) + if err != nil { + return nil, err + } + return repo.Eventstore.FilterEvents(ctx, query) +} diff --git a/internal/management/repository/eventsourcing/eventstore/org.go b/internal/management/repository/eventsourcing/eventstore/org.go index 1784cf2456..e0193594ca 100644 --- a/internal/management/repository/eventsourcing/eventstore/org.go +++ b/internal/management/repository/eventsourcing/eventstore/org.go @@ -12,6 +12,7 @@ import ( "github.com/caos/zitadel/internal/user/repository/view" "github.com/golang/protobuf/ptypes" "strings" + "time" "github.com/caos/logging" "github.com/caos/zitadel/internal/api/authz" @@ -93,8 +94,8 @@ func (repo *OrgRepository) SearchMyOrgDomains(ctx context.Context, request *org_ return result, nil } -func (repo *OrgRepository) OrgChanges(ctx context.Context, id string, lastSequence uint64, limit uint64, sortAscending bool) (*org_model.OrgChanges, error) { - changes, err := repo.getOrgChanges(ctx, id, lastSequence, limit, sortAscending) +func (repo *OrgRepository) OrgChanges(ctx context.Context, id string, lastSequence uint64, limit uint64, sortAscending bool, auditLogRetention time.Duration) (*org_model.OrgChanges, error) { + changes, err := repo.getOrgChanges(ctx, id, lastSequence, limit, sortAscending, auditLogRetention) if err != nil { return nil, err } @@ -537,8 +538,8 @@ func (repo *OrgRepository) GetMailTexts(ctx context.Context) (*iam_model.MailTex return iam_es_model.MailTextsViewToModel(texts, defaultIn), err } -func (repo *OrgRepository) getOrgChanges(ctx context.Context, orgID string, lastSequence uint64, limit uint64, sortAscending bool) (*org_model.OrgChanges, error) { - query := org_view.ChangesQuery(orgID, lastSequence, limit, sortAscending) +func (repo *OrgRepository) getOrgChanges(ctx context.Context, orgID string, lastSequence uint64, limit uint64, sortAscending bool, auditLogRetention time.Duration) (*org_model.OrgChanges, error) { + query := org_view.ChangesQuery(orgID, lastSequence, limit, sortAscending, auditLogRetention) events, err := repo.Eventstore.FilterEvents(context.Background(), query) if err != nil { diff --git a/internal/management/repository/eventsourcing/eventstore/project.go b/internal/management/repository/eventsourcing/eventstore/project.go index 1369fdc10f..73da85b87f 100644 --- a/internal/management/repository/eventsourcing/eventstore/project.go +++ b/internal/management/repository/eventsourcing/eventstore/project.go @@ -10,6 +10,7 @@ import ( iam_es_model "github.com/caos/zitadel/internal/iam/repository/eventsourcing/model" iam_view "github.com/caos/zitadel/internal/iam/repository/view" "strings" + "time" "github.com/caos/logging" "github.com/golang/protobuf/ptypes" @@ -180,8 +181,8 @@ func (repo *ProjectRepo) SearchProjectRoles(ctx context.Context, projectID strin return result, nil } -func (repo *ProjectRepo) ProjectChanges(ctx context.Context, id string, lastSequence uint64, limit uint64, sortAscending bool) (*proj_model.ProjectChanges, error) { - changes, err := repo.getProjectChanges(ctx, id, lastSequence, limit, sortAscending) +func (repo *ProjectRepo) ProjectChanges(ctx context.Context, id string, lastSequence uint64, limit uint64, sortAscending bool, retention time.Duration) (*proj_model.ProjectChanges, error) { + changes, err := repo.getProjectChanges(ctx, id, lastSequence, limit, sortAscending, retention) if err != nil { return nil, err } @@ -254,8 +255,8 @@ func (repo *ProjectRepo) SearchApplications(ctx context.Context, request *proj_m return result, nil } -func (repo *ProjectRepo) ApplicationChanges(ctx context.Context, id string, appId string, lastSequence uint64, limit uint64, sortAscending bool) (*proj_model.ApplicationChanges, error) { - changes, err := repo.getApplicationChanges(ctx, id, appId, lastSequence, limit, sortAscending) +func (repo *ProjectRepo) ApplicationChanges(ctx context.Context, projectID string, appID string, lastSequence uint64, limit uint64, sortAscending bool, retention time.Duration) (*proj_model.ApplicationChanges, error) { + changes, err := repo.getApplicationChanges(ctx, projectID, appID, lastSequence, limit, sortAscending, retention) if err != nil { return nil, err } @@ -505,8 +506,8 @@ func (r *ProjectRepo) getUserEvents(ctx context.Context, userID string, sequence return r.Eventstore.FilterEvents(ctx, query) } -func (repo *ProjectRepo) getProjectChanges(ctx context.Context, id string, lastSequence uint64, limit uint64, sortAscending bool) (*proj_model.ProjectChanges, error) { - query := proj_view.ChangesQuery(id, lastSequence, limit, sortAscending) +func (repo *ProjectRepo) getProjectChanges(ctx context.Context, id string, lastSequence uint64, limit uint64, sortAscending bool, retention time.Duration) (*proj_model.ProjectChanges, error) { + query := proj_view.ChangesQuery(id, lastSequence, limit, sortAscending, retention) events, err := repo.Eventstore.FilterEvents(context.Background(), query) if err != nil { @@ -561,8 +562,8 @@ func (repo *ProjectRepo) getProjectEvents(ctx context.Context, id string, sequen return repo.Eventstore.FilterEvents(ctx, query) } -func (repo *ProjectRepo) getApplicationChanges(ctx context.Context, projectID string, appID string, lastSequence uint64, limit uint64, sortAscending bool) (*proj_model.ApplicationChanges, error) { - query := proj_view.ChangesQuery(projectID, lastSequence, limit, sortAscending) +func (repo *ProjectRepo) getApplicationChanges(ctx context.Context, projectID string, appID string, lastSequence uint64, limit uint64, sortAscending bool, retention time.Duration) (*proj_model.ApplicationChanges, error) { + query := proj_view.ChangesQuery(projectID, lastSequence, limit, sortAscending, retention) events, err := repo.Eventstore.FilterEvents(ctx, query) if err != nil { diff --git a/internal/management/repository/eventsourcing/eventstore/user.go b/internal/management/repository/eventsourcing/eventstore/user.go index 6194d0ed02..07b6cdffeb 100644 --- a/internal/management/repository/eventsourcing/eventstore/user.go +++ b/internal/management/repository/eventsourcing/eventstore/user.go @@ -2,6 +2,8 @@ package eventstore import ( "context" + "time" + "github.com/caos/zitadel/internal/domain" "github.com/caos/zitadel/internal/eventstore/v1" "github.com/caos/zitadel/internal/eventstore/v1/models" @@ -82,8 +84,8 @@ func (repo *UserRepo) UserIDsByDomain(ctx context.Context, domain string) ([]str return repo.View.UserIDsByDomain(domain) } -func (repo *UserRepo) UserChanges(ctx context.Context, id string, lastSequence uint64, limit uint64, sortAscending bool) (*usr_model.UserChanges, error) { - changes, err := repo.getUserChanges(ctx, id, lastSequence, limit, sortAscending) +func (repo *UserRepo) UserChanges(ctx context.Context, id string, lastSequence uint64, limit uint64, sortAscending bool, retention time.Duration) (*usr_model.UserChanges, error) { + changes, err := repo.getUserChanges(ctx, id, lastSequence, limit, sortAscending, retention) if err != nil { return nil, err } @@ -280,8 +282,8 @@ func (repo *UserRepo) SearchUserMemberships(ctx context.Context, request *usr_mo return result, nil } -func (r *UserRepo) getUserChanges(ctx context.Context, userID string, lastSequence uint64, limit uint64, sortAscending bool) (*usr_model.UserChanges, error) { - query := usr_view.ChangesQuery(userID, lastSequence, limit, sortAscending) +func (r *UserRepo) getUserChanges(ctx context.Context, userID string, lastSequence uint64, limit uint64, sortAscending bool, retention time.Duration) (*usr_model.UserChanges, error) { + query := usr_view.ChangesQuery(userID, lastSequence, limit, sortAscending, retention) events, err := r.Eventstore.FilterEvents(ctx, query) if err != nil { diff --git a/internal/management/repository/eventsourcing/handler/features.go b/internal/management/repository/eventsourcing/handler/features.go new file mode 100644 index 0000000000..5f4f556e64 --- /dev/null +++ b/internal/management/repository/eventsourcing/handler/features.go @@ -0,0 +1,165 @@ +package handler + +import ( + "context" + + "github.com/caos/logging" + + "github.com/caos/zitadel/internal/domain" + caos_errs "github.com/caos/zitadel/internal/errors" + "github.com/caos/zitadel/internal/eventstore/v1" + es_models "github.com/caos/zitadel/internal/eventstore/v1/models" + "github.com/caos/zitadel/internal/eventstore/v1/query" + "github.com/caos/zitadel/internal/eventstore/v1/spooler" + "github.com/caos/zitadel/internal/features/repository/view/model" + "github.com/caos/zitadel/internal/iam/repository/eventsourcing" + iam_es_model "github.com/caos/zitadel/internal/iam/repository/eventsourcing/model" + org_es_model "github.com/caos/zitadel/internal/org/repository/eventsourcing/model" + iam_repo "github.com/caos/zitadel/internal/repository/iam" + org_repo "github.com/caos/zitadel/internal/repository/org" +) + +const ( + featuresTable = "management.features" +) + +type Features struct { + handler + subscription *v1.Subscription +} + +func newFeatures(handler handler) *Features { + h := &Features{ + handler: handler, + } + + h.subscribe() + + return h +} + +func (p *Features) subscribe() { + p.subscription = p.es.Subscribe(p.AggregateTypes()...) + go func() { + for event := range p.subscription.Events { + query.ReduceEvent(p, event) + } + }() +} + +func (p *Features) ViewModel() string { + return featuresTable +} + +func (p *Features) AggregateTypes() []es_models.AggregateType { + return []es_models.AggregateType{iam_es_model.IAMAggregate, org_es_model.OrgAggregate} +} + +func (p *Features) EventQuery() (*es_models.SearchQuery, error) { + sequence, err := p.view.GetLatestFeaturesSequence() + if err != nil { + return nil, err + } + return es_models.NewSearchQuery(). + AggregateTypeFilter(p.AggregateTypes()...). + LatestSequenceFilter(sequence.CurrentSequence), nil +} + +func (p *Features) CurrentSequence() (uint64, error) { + sequence, err := p.view.GetLatestFeaturesSequence() + if err != nil { + return 0, err + } + return sequence.CurrentSequence, nil +} + +func (p *Features) Reduce(event *es_models.Event) (err error) { + switch event.AggregateType { + case org_es_model.OrgAggregate, iam_es_model.IAMAggregate: + err = p.processFeatures(event) + } + return err +} + +func (p *Features) processFeatures(event *es_models.Event) (err error) { + features := new(model.FeaturesView) + switch string(event.Type) { + case string(org_es_model.OrgAdded): + features, err = p.getDefaultFeatures() + if err != nil { + return err + } + features.AggregateID = event.AggregateID + features.Default = true + case string(iam_repo.FeaturesSetEventType): + defaultFeatures, err := p.view.AllDefaultFeatures() + if err != nil { + return err + } + for _, features := range defaultFeatures { + err = features.AppendEvent(event) + if err != nil { + return err + } + } + return p.view.PutFeaturesList(defaultFeatures, event) + case string(org_repo.FeaturesSetEventType): + features, err = p.view.FeaturesByAggregateID(event.AggregateID) + if err != nil { + return err + } + err = features.AppendEvent(event) + case string(org_repo.FeaturesRemovedEventType): + features, err = p.getDefaultFeatures() + if err != nil { + return err + } + features.AggregateID = event.AggregateID + features.Default = true + default: + return p.view.ProcessedFeaturesSequence(event) + } + if err != nil { + return err + } + return p.view.PutFeatures(features, event) +} + +func (p *Features) OnError(event *es_models.Event, err error) error { + logging.LogWithFields("SPOOL-Wj8sf", "id", event.AggregateID).WithError(err).Warn("something went wrong in login features handler") + return spooler.HandleError(event, err, p.view.GetLatestFeaturesFailedEvent, p.view.ProcessedFeaturesFailedEvent, p.view.ProcessedFeaturesSequence, p.errorCountUntilSkip) +} + +func (p *Features) OnSuccess() error { + return spooler.HandleSuccess(p.view.UpdateFeaturesSpoolerRunTimestamp) +} + +func (p *Features) getDefaultFeatures() (*model.FeaturesView, error) { + features, featuresErr := p.view.FeaturesByAggregateID(domain.IAMID) + if featuresErr != nil && !caos_errs.IsNotFound(featuresErr) { + return nil, featuresErr + } + if features == nil { + features = &model.FeaturesView{} + } + events, err := p.getIAMEvents(features.Sequence) + if err != nil { + return features, featuresErr + } + featuresCopy := *features + for _, event := range events { + if err := featuresCopy.AppendEvent(event); err != nil { + return features, nil + } + } + return &featuresCopy, nil +} + +func (p *Features) getIAMEvents(sequence uint64) ([]*es_models.Event, error) { + query, err := eventsourcing.IAMByIDQuery(domain.IAMID, sequence) + if err != nil { + return nil, err + } + + return p.es.FilterEvents(context.Background(), query) +} diff --git a/internal/management/repository/eventsourcing/handler/handler.go b/internal/management/repository/eventsourcing/handler/handler.go index 6ae5d28982..a116b3a3bc 100644 --- a/internal/management/repository/eventsourcing/handler/handler.go +++ b/internal/management/repository/eventsourcing/handler/handler.go @@ -1,9 +1,10 @@ package handler import ( - "github.com/caos/zitadel/internal/eventstore/v1" "time" + "github.com/caos/zitadel/internal/eventstore/v1" + "github.com/caos/zitadel/internal/config/systemdefaults" "github.com/caos/zitadel/internal/config/types" "github.com/caos/zitadel/internal/eventstore/v1/query" @@ -76,6 +77,8 @@ func Register(configs Configs, bulkLimit, errorCount uint64, view *view.View, es handler{view, bulkLimit, configs.cycleDuration("MailTemplate"), errorCount, es}), newMailText( handler{view, bulkLimit, configs.cycleDuration("MailText"), errorCount, es}), + newFeatures( + handler{view, bulkLimit, configs.cycleDuration("Features"), errorCount, es}), } } diff --git a/internal/management/repository/eventsourcing/repository.go b/internal/management/repository/eventsourcing/repository.go index 759cd35c80..ac1649d1a4 100644 --- a/internal/management/repository/eventsourcing/repository.go +++ b/internal/management/repository/eventsourcing/repository.go @@ -27,6 +27,7 @@ type EsRepository struct { eventstore.UserRepo eventstore.UserGrantRepo eventstore.IAMRepository + eventstore.FeaturesRepo view *mgmt_view.View } @@ -54,10 +55,9 @@ func Start(conf Config, systemDefaults sd.SystemDefaults, roles []string, querie ProjectRepo: eventstore.ProjectRepo{es, conf.SearchLimit, view, roles, systemDefaults.IamID}, UserRepo: eventstore.UserRepo{es, conf.SearchLimit, view, systemDefaults}, UserGrantRepo: eventstore.UserGrantRepo{conf.SearchLimit, view}, - IAMRepository: eventstore.IAMRepository{ - IAMV2Query: queries, - }, - view: view, + IAMRepository: eventstore.IAMRepository{IAMV2Query: queries}, + FeaturesRepo: eventstore.FeaturesRepo{es, view, conf.SearchLimit, systemDefaults}, + view: view, }, nil } diff --git a/internal/management/repository/eventsourcing/view/features.go b/internal/management/repository/eventsourcing/view/features.go new file mode 100644 index 0000000000..da966f5c7d --- /dev/null +++ b/internal/management/repository/eventsourcing/view/features.go @@ -0,0 +1,56 @@ +package view + +import ( + "github.com/caos/zitadel/internal/eventstore/v1/models" + "github.com/caos/zitadel/internal/features/repository/view" + "github.com/caos/zitadel/internal/features/repository/view/model" + global_view "github.com/caos/zitadel/internal/view/repository" +) + +const ( + featuresTable = "management.features" +) + +func (v *View) AllDefaultFeatures() ([]*model.FeaturesView, error) { + return view.GetDefaultFeatures(v.Db, featuresTable) +} + +func (v *View) FeaturesByAggregateID(aggregateID string) (*model.FeaturesView, error) { + return view.GetFeaturesByAggregateID(v.Db, featuresTable, aggregateID) +} + +func (v *View) PutFeatures(features *model.FeaturesView, event *models.Event) error { + err := view.PutFeatures(v.Db, featuresTable, features) + if err != nil { + return err + } + return v.ProcessedFeaturesSequence(event) +} + +func (v *View) PutFeaturesList(features []*model.FeaturesView, event *models.Event) error { + err := view.PutFeaturesList(v.Db, featuresTable, features...) + if err != nil { + return err + } + return v.ProcessedFeaturesSequence(event) +} + +func (v *View) GetLatestFeaturesSequence() (*global_view.CurrentSequence, error) { + return v.latestSequence(featuresTable) +} + +func (v *View) ProcessedFeaturesSequence(event *models.Event) error { + return v.saveCurrentSequence(featuresTable, event) +} + +func (v *View) UpdateFeaturesSpoolerRunTimestamp() error { + return v.updateSpoolerRunSequence(featuresTable) +} + +func (v *View) GetLatestFeaturesFailedEvent(sequence uint64) (*global_view.FailedEvent, error) { + return v.latestFailedEvent(featuresTable, sequence) +} + +func (v *View) ProcessedFeaturesFailedEvent(failedEvent *global_view.FailedEvent) error { + return v.saveFailedEvent(failedEvent) +} diff --git a/internal/management/repository/features.go b/internal/management/repository/features.go new file mode 100644 index 0000000000..1c01ab4d00 --- /dev/null +++ b/internal/management/repository/features.go @@ -0,0 +1,11 @@ +package repository + +import ( + "context" + + features_model "github.com/caos/zitadel/internal/features/model" +) + +type FeaturesRepository interface { + GetOrgFeatures(ctx context.Context, id string) (*features_model.FeaturesView, error) +} diff --git a/internal/management/repository/org.go b/internal/management/repository/org.go index 97a2094baf..285f95ebfd 100644 --- a/internal/management/repository/org.go +++ b/internal/management/repository/org.go @@ -2,6 +2,7 @@ package repository import ( "context" + "time" iam_model "github.com/caos/zitadel/internal/iam/model" @@ -11,7 +12,7 @@ import ( type OrgRepository interface { OrgByID(ctx context.Context, id string) (*org_model.OrgView, error) OrgByDomainGlobal(ctx context.Context, domain string) (*org_model.OrgView, error) - OrgChanges(ctx context.Context, id string, lastSequence uint64, limit uint64, sortAscending bool) (*org_model.OrgChanges, error) + OrgChanges(ctx context.Context, id string, lastSequence uint64, limit uint64, sortAscending bool, auditLogRetention time.Duration) (*org_model.OrgChanges, error) SearchMyOrgDomains(ctx context.Context, request *org_model.OrgDomainSearchRequest) (*org_model.OrgDomainSearchResponse, error) diff --git a/internal/management/repository/project.go b/internal/management/repository/project.go index c91f8b3d6d..7516195c47 100644 --- a/internal/management/repository/project.go +++ b/internal/management/repository/project.go @@ -2,6 +2,8 @@ package repository import ( "context" + "time" + iam_model "github.com/caos/zitadel/internal/iam/model" key_model "github.com/caos/zitadel/internal/key/model" @@ -22,11 +24,11 @@ type ProjectRepository interface { GetProjectMemberRoles(ctx context.Context) ([]string, error) SearchProjectRoles(ctx context.Context, projectId string, request *model.ProjectRoleSearchRequest) (*model.ProjectRoleSearchResponse, error) - ProjectChanges(ctx context.Context, id string, lastSequence uint64, limit uint64, sortAscending bool) (*model.ProjectChanges, error) + ProjectChanges(ctx context.Context, id string, lastSequence uint64, limit uint64, sortAscending bool, retention time.Duration) (*model.ProjectChanges, error) ApplicationByID(ctx context.Context, projectID, appID string) (*model.ApplicationView, error) SearchApplications(ctx context.Context, request *model.ApplicationSearchRequest) (*model.ApplicationSearchResponse, error) - ApplicationChanges(ctx context.Context, projectID string, appID string, lastSequence uint64, limit uint64, sortAscending bool) (*model.ApplicationChanges, error) + ApplicationChanges(ctx context.Context, projectID string, appID string, lastSequence uint64, limit uint64, sortAscending bool, retention time.Duration) (*model.ApplicationChanges, error) SearchClientKeys(ctx context.Context, request *key_model.AuthNKeySearchRequest) (*key_model.AuthNKeySearchResponse, error) GetClientKey(ctx context.Context, projectID, applicationID, keyID string) (*key_model.AuthNKeyView, error) diff --git a/internal/management/repository/repository.go b/internal/management/repository/repository.go index 0ed2044712..cb45972e00 100644 --- a/internal/management/repository/repository.go +++ b/internal/management/repository/repository.go @@ -7,4 +7,5 @@ type Repository interface { UserRepository UserGrantRepository IamRepository + FeaturesRepository } diff --git a/internal/management/repository/user.go b/internal/management/repository/user.go index 5a65b5774b..8edad71483 100644 --- a/internal/management/repository/user.go +++ b/internal/management/repository/user.go @@ -2,6 +2,7 @@ package repository import ( "context" + "time" key_model "github.com/caos/zitadel/internal/key/model" "github.com/caos/zitadel/internal/user/model" @@ -15,7 +16,7 @@ type UserRepository interface { GetUserByLoginNameGlobal(ctx context.Context, email string) (*model.UserView, error) IsUserUnique(ctx context.Context, userName, email string) (bool, error) - UserChanges(ctx context.Context, id string, lastSequence uint64, limit uint64, sortAscending bool) (*model.UserChanges, error) + UserChanges(ctx context.Context, id string, lastSequence uint64, limit uint64, sortAscending bool, retention time.Duration) (*model.UserChanges, error) ProfileByID(ctx context.Context, userID string) (*model.Profile, error) diff --git a/internal/org/repository/view/query.go b/internal/org/repository/view/query.go index cc82c41e3e..33b8a1d54d 100644 --- a/internal/org/repository/view/query.go +++ b/internal/org/repository/view/query.go @@ -1,6 +1,8 @@ package view import ( + "time" + "github.com/caos/zitadel/internal/errors" es_models "github.com/caos/zitadel/internal/eventstore/v1/models" "github.com/caos/zitadel/internal/org/repository/eventsourcing/model" @@ -36,13 +38,16 @@ func OrgNameUniqueQuery(name string) *es_models.SearchQuery { SetLimit(1) } -func ChangesQuery(orgID string, latestSequence, limit uint64, sortAscending bool) *es_models.SearchQuery { +func ChangesQuery(orgID string, latestSequence, limit uint64, sortAscending bool, auditLogRetention time.Duration) *es_models.SearchQuery { query := es_models.NewSearchQuery(). AggregateTypeFilter(model.OrgAggregate) if !sortAscending { query.OrderDesc() } + if auditLogRetention > 0 { + query.CreationDateNewerFilter(time.Now().Add(-auditLogRetention)) + } query.LatestSequenceFilter(latestSequence). AggregateIDFilter(orgID). diff --git a/internal/project/repository/view/query.go b/internal/project/repository/view/query.go index b4fabf2629..174d9a4931 100644 --- a/internal/project/repository/view/query.go +++ b/internal/project/repository/view/query.go @@ -1,6 +1,8 @@ package view import ( + "time" + "github.com/caos/zitadel/internal/errors" es_models "github.com/caos/zitadel/internal/eventstore/v1/models" "github.com/caos/zitadel/internal/project/repository/eventsourcing/model" @@ -20,12 +22,15 @@ func ProjectQuery(latestSequence uint64) *es_models.SearchQuery { LatestSequenceFilter(latestSequence) } -func ChangesQuery(projectID string, latestSequence, limit uint64, sortAscending bool) *es_models.SearchQuery { +func ChangesQuery(projectID string, latestSequence, limit uint64, sortAscending bool, retention time.Duration) *es_models.SearchQuery { query := es_models.NewSearchQuery(). AggregateTypeFilter(model.ProjectAggregate) if !sortAscending { query.OrderDesc() } + if retention > 0 { + query.CreationDateNewerFilter(time.Now().Add(-retention)) + } query.LatestSequenceFilter(latestSequence). AggregateIDFilter(projectID). diff --git a/internal/protoc/protoc-gen-authoption/authoption/options.proto b/internal/protoc/protoc-gen-authoption/authoption/options.proto index 0d86e81073..0e3678d3e4 100644 --- a/internal/protoc/protoc-gen-authoption/authoption/options.proto +++ b/internal/protoc/protoc-gen-authoption/authoption/options.proto @@ -14,4 +14,5 @@ extend google.protobuf.MethodOptions { message AuthOption { string permission = 1; string check_field_name = 2; + string feature = 3; } \ No newline at end of file diff --git a/internal/protoc/protoc-gen-authoption/templates/auth_method_mapping.go.tmpl b/internal/protoc/protoc-gen-authoption/templates/auth_method_mapping.go.tmpl index 0f54c222e3..a910f80860 100644 --- a/internal/protoc/protoc-gen-authoption/templates/auth_method_mapping.go.tmpl +++ b/internal/protoc/protoc-gen-authoption/templates/auth_method_mapping.go.tmpl @@ -21,10 +21,11 @@ const {{$s.Name}}_MethodPrefix = "{{$.File.Package}}.{{$s.Name}}" var {{$s.Name}}_AuthMethods = authz.MethodMapping { {{ range $m := $s.Method}} {{ $mAuthOpt := option $m.Options "zitadel.v1.auth_option" }} - {{ if and $mAuthOpt $mAuthOpt.Permission }} + {{ if and $mAuthOpt (or $mAuthOpt.Permission $mAuthOpt.Feature) }} "/{{$.File.Package}}.{{$s.Name}}/{{.Name}}": authz.Option{ Permission: "{{$mAuthOpt.Permission}}", CheckParam: "{{$mAuthOpt.CheckFieldName}}", + Feature: "{{$mAuthOpt.Feature}}", }, {{end}} {{ end}} diff --git a/internal/repository/features/features.go b/internal/repository/features/features.go new file mode 100644 index 0000000000..915749f28d --- /dev/null +++ b/internal/repository/features/features.go @@ -0,0 +1,169 @@ +package features + +import ( + "encoding/json" + "time" + + "github.com/caos/zitadel/internal/domain" + "github.com/caos/zitadel/internal/errors" + "github.com/caos/zitadel/internal/eventstore" + "github.com/caos/zitadel/internal/eventstore/repository" +) + +const ( + featuresPrefix = "features." + FeaturesSetEventType = featuresPrefix + "set" + FeaturesRemovedEventType = featuresPrefix + "removed" +) + +type FeaturesSetEvent struct { + eventstore.BaseEvent `json:"-"` + + TierName *string `json:"tierName,omitempty"` + TierDescription *string `json:"tierDescription,omitempty"` + State *domain.FeaturesState `json:"state,omitempty"` + StateDescription *string `json:"stateDescription,omitempty"` + AuditLogRetention *time.Duration `json:"auditLogRetention,omitempty"` + LoginPolicyFactors *bool `json:"loginPolicyFactors,omitempty"` + LoginPolicyIDP *bool `json:"loginPolicyIDP,omitempty"` + LoginPolicyPasswordless *bool `json:"loginPolicyPasswordless,omitempty"` + LoginPolicyRegistration *bool `json:"loginPolicyRegistration,omitempty"` + LoginPolicyUsernameLogin *bool `json:"loginPolicyUsername_login,omitempty"` + PasswordComplexityPolicy *bool `json:"passwordComplexityPolicy,omitempty"` + LabelPolicy *bool `json:"labelPolicy,omitempty"` +} + +func (e *FeaturesSetEvent) Data() interface{} { + return e +} + +func (e *FeaturesSetEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint { + return nil +} + +func NewFeaturesSetEvent( + base *eventstore.BaseEvent, + changes []FeaturesChanges, +) (*FeaturesSetEvent, error) { + if len(changes) == 0 { + return nil, errors.ThrowPreconditionFailed(nil, "FEATURES-d34F4", "Errors.NoChangesFound") + } + changeEvent := &FeaturesSetEvent{ + BaseEvent: *base, + } + for _, change := range changes { + change(changeEvent) + } + return changeEvent, nil +} + +type FeaturesChanges func(*FeaturesSetEvent) + +func ChangeTierName(tierName string) func(event *FeaturesSetEvent) { + return func(e *FeaturesSetEvent) { + e.TierName = &tierName + } +} + +func ChangeTierDescription(tierDescription string) func(event *FeaturesSetEvent) { + return func(e *FeaturesSetEvent) { + e.TierDescription = &tierDescription + } +} + +func ChangeState(State domain.FeaturesState) func(event *FeaturesSetEvent) { + return func(e *FeaturesSetEvent) { + e.State = &State + } +} + +func ChangeStateDescription(statusDescription string) func(event *FeaturesSetEvent) { + return func(e *FeaturesSetEvent) { + e.StateDescription = &statusDescription + } +} + +func ChangeAuditLogRetention(retention time.Duration) func(event *FeaturesSetEvent) { + return func(e *FeaturesSetEvent) { + e.AuditLogRetention = &retention + } +} + +func ChangeLoginPolicyFactors(loginPolicyFactors bool) func(event *FeaturesSetEvent) { + return func(e *FeaturesSetEvent) { + e.LoginPolicyFactors = &loginPolicyFactors + } +} + +func ChangeLoginPolicyIDP(loginPolicyIDP bool) func(event *FeaturesSetEvent) { + return func(e *FeaturesSetEvent) { + e.LoginPolicyIDP = &loginPolicyIDP + } +} + +func ChangeLoginPolicyPasswordless(loginPolicyPasswordless bool) func(event *FeaturesSetEvent) { + return func(e *FeaturesSetEvent) { + e.LoginPolicyPasswordless = &loginPolicyPasswordless + } +} + +func ChangeLoginPolicyRegistration(loginPolicyRegistration bool) func(event *FeaturesSetEvent) { + return func(e *FeaturesSetEvent) { + e.LoginPolicyRegistration = &loginPolicyRegistration + } +} + +func ChangeLoginPolicyUsernameLogin(loginPolicyUsernameLogin bool) func(event *FeaturesSetEvent) { + return func(e *FeaturesSetEvent) { + e.LoginPolicyUsernameLogin = &loginPolicyUsernameLogin + } +} + +func ChangePasswordComplexityPolicy(passwordComplexityPolicy bool) func(event *FeaturesSetEvent) { + return func(e *FeaturesSetEvent) { + e.PasswordComplexityPolicy = &passwordComplexityPolicy + } +} + +func ChangeLabelPolicy(labelPolicy bool) func(event *FeaturesSetEvent) { + return func(e *FeaturesSetEvent) { + e.LabelPolicy = &labelPolicy + } +} + +func FeaturesSetEventMapper(event *repository.Event) (eventstore.EventReader, error) { + e := &FeaturesSetEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(event), + } + + err := json.Unmarshal(event.Data, e) + if err != nil { + return nil, errors.ThrowInternal(err, "FEATURES-fdgDg", "unable to unmarshal features") + } + + return e, nil +} + +type FeaturesRemovedEvent struct { + eventstore.BaseEvent `json:"-"` +} + +func (e *FeaturesRemovedEvent) Data() interface{} { + return nil +} + +func (e *FeaturesRemovedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint { + return nil +} + +func NewFeaturesRemovedEvent(base *eventstore.BaseEvent) *FeaturesRemovedEvent { + return &FeaturesRemovedEvent{ + BaseEvent: *base, + } +} + +func FeaturesRemovedEventMapper(event *repository.Event) (eventstore.EventReader, error) { + return &FeaturesRemovedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(event), + }, nil +} diff --git a/internal/repository/iam/eventstore.go b/internal/repository/iam/eventstore.go index 44f4d86d8e..d50e6658b4 100644 --- a/internal/repository/iam/eventstore.go +++ b/internal/repository/iam/eventstore.go @@ -42,5 +42,6 @@ func RegisterEventMappers(es *eventstore.Eventstore) { RegisterFilterEventMapper(MailTemplateAddedEventType, MailTemplateAddedEventMapper). RegisterFilterEventMapper(MailTemplateChangedEventType, MailTemplateChangedEventMapper). RegisterFilterEventMapper(MailTextAddedEventType, MailTextAddedEventMapper). - RegisterFilterEventMapper(MailTextChangedEventType, MailTextChangedEventMapper) + RegisterFilterEventMapper(MailTextChangedEventType, MailTextChangedEventMapper). + RegisterFilterEventMapper(FeaturesSetEventType, FeaturesSetEventMapper) } diff --git a/internal/repository/iam/features.go b/internal/repository/iam/features.go new file mode 100644 index 0000000000..de3d19e9d8 --- /dev/null +++ b/internal/repository/iam/features.go @@ -0,0 +1,43 @@ +package iam + +import ( + "context" + "github.com/caos/zitadel/internal/eventstore" + "github.com/caos/zitadel/internal/eventstore/repository" + "github.com/caos/zitadel/internal/repository/features" +) + +var ( + FeaturesSetEventType = iamEventTypePrefix + features.FeaturesSetEventType +) + +type FeaturesSetEvent struct { + features.FeaturesSetEvent +} + +func NewFeaturesSetEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + changes []features.FeaturesChanges, +) (*FeaturesSetEvent, error) { + changedEvent, err := features.NewFeaturesSetEvent( + eventstore.NewBaseEventForPush( + ctx, + aggregate, + FeaturesSetEventType), + changes, + ) + if err != nil { + return nil, err + } + return &FeaturesSetEvent{FeaturesSetEvent: *changedEvent}, nil +} + +func FeaturesSetEventMapper(event *repository.Event) (eventstore.EventReader, error) { + e, err := features.FeaturesSetEventMapper(event) + if err != nil { + return nil, err + } + + return &FeaturesSetEvent{FeaturesSetEvent: *e.(*features.FeaturesSetEvent)}, nil +} diff --git a/internal/repository/org/eventstore.go b/internal/repository/org/eventstore.go index 2b4067a063..990fe160cb 100644 --- a/internal/repository/org/eventstore.go +++ b/internal/repository/org/eventstore.go @@ -55,5 +55,7 @@ func RegisterEventMappers(es *eventstore.Eventstore) { RegisterFilterEventMapper(IDPConfigDeactivatedEventType, IDPConfigDeactivatedEventMapper). RegisterFilterEventMapper(IDPConfigReactivatedEventType, IDPConfigReactivatedEventMapper). RegisterFilterEventMapper(IDPOIDCConfigAddedEventType, IDPOIDCConfigAddedEventMapper). - RegisterFilterEventMapper(IDPOIDCConfigChangedEventType, IDPOIDCConfigChangedEventMapper) + RegisterFilterEventMapper(IDPOIDCConfigChangedEventType, IDPOIDCConfigChangedEventMapper). + RegisterFilterEventMapper(FeaturesSetEventType, FeaturesSetEventMapper). + RegisterFilterEventMapper(FeaturesRemovedEventType, FeaturesRemovedEventMapper) } diff --git a/internal/repository/org/features.go b/internal/repository/org/features.go new file mode 100644 index 0000000000..609fedab4d --- /dev/null +++ b/internal/repository/org/features.go @@ -0,0 +1,71 @@ +package org + +import ( + "context" + "github.com/caos/zitadel/internal/eventstore" + "github.com/caos/zitadel/internal/eventstore/repository" + "github.com/caos/zitadel/internal/repository/features" +) + +var ( + FeaturesSetEventType = orgEventTypePrefix + features.FeaturesSetEventType + FeaturesRemovedEventType = orgEventTypePrefix + features.FeaturesRemovedEventType +) + +type FeaturesSetEvent struct { + features.FeaturesSetEvent +} + +func NewFeaturesSetEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + changes []features.FeaturesChanges, +) (*FeaturesSetEvent, error) { + changedEvent, err := features.NewFeaturesSetEvent( + eventstore.NewBaseEventForPush( + ctx, + aggregate, + FeaturesSetEventType), + changes, + ) + if err != nil { + return nil, err + } + return &FeaturesSetEvent{FeaturesSetEvent: *changedEvent}, nil +} + +func FeaturesSetEventMapper(event *repository.Event) (eventstore.EventReader, error) { + e, err := features.FeaturesSetEventMapper(event) + if err != nil { + return nil, err + } + + return &FeaturesSetEvent{FeaturesSetEvent: *e.(*features.FeaturesSetEvent)}, nil +} + +type FeaturesRemovedEvent struct { + features.FeaturesRemovedEvent +} + +func NewFeaturesRemovedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, +) *FeaturesRemovedEvent { + return &FeaturesRemovedEvent{ + FeaturesRemovedEvent: *features.NewFeaturesRemovedEvent( + eventstore.NewBaseEventForPush( + ctx, + aggregate, + FeaturesRemovedEventType), + ), + } +} + +func FeaturesRemovedEventMapper(event *repository.Event) (eventstore.EventReader, error) { + e, err := features.FeaturesRemovedEventMapper(event) + if err != nil { + return nil, err + } + + return &FeaturesRemovedEvent{FeaturesRemovedEvent: *e.(*features.FeaturesRemovedEvent)}, nil +} diff --git a/internal/setup/config.go b/internal/setup/config.go index 95b2856d55..977b7fad45 100644 --- a/internal/setup/config.go +++ b/internal/setup/config.go @@ -17,6 +17,7 @@ type IAMSetUp struct { Step9 *command.Step9 Step10 *command.Step10 Step11 *command.Step11 + Step12 *command.Step12 } func (setup *IAMSetUp) Steps(currentDone domain.Step) ([]command.Step, error) { @@ -34,6 +35,7 @@ func (setup *IAMSetUp) Steps(currentDone domain.Step) ([]command.Step, error) { setup.Step9, setup.Step10, setup.Step11, + setup.Step12, } { if step.Step() <= currentDone { continue diff --git a/internal/user/repository/view/query.go b/internal/user/repository/view/query.go index 6f19f5c4d3..0e2af77ad2 100644 --- a/internal/user/repository/view/query.go +++ b/internal/user/repository/view/query.go @@ -1,6 +1,8 @@ package view import ( + "time" + "github.com/caos/zitadel/internal/errors" es_models "github.com/caos/zitadel/internal/eventstore/v1/models" "github.com/caos/zitadel/internal/user/repository/eventsourcing/model" @@ -20,12 +22,15 @@ func UserQuery(latestSequence uint64) *es_models.SearchQuery { LatestSequenceFilter(latestSequence) } -func ChangesQuery(userID string, latestSequence, limit uint64, sortAscending bool) *es_models.SearchQuery { +func ChangesQuery(userID string, latestSequence, limit uint64, sortAscending bool, retention time.Duration) *es_models.SearchQuery { query := es_models.NewSearchQuery(). AggregateTypeFilter(model.UserAggregate) if !sortAscending { query.OrderDesc() } + if retention > 0 { + query.CreationDateNewerFilter(time.Now().Add(-retention)) + } query.LatestSequenceFilter(latestSequence). AggregateIDFilter(userID). diff --git a/migrations/cockroach/V1.35__features.sql b/migrations/cockroach/V1.35__features.sql new file mode 100644 index 0000000000..de4ce57368 --- /dev/null +++ b/migrations/cockroach/V1.35__features.sql @@ -0,0 +1,103 @@ +CREATE TABLE adminapi.features +( + aggregate_id TEXT, + + creation_date TIMESTAMPTZ, + change_date TIMESTAMPTZ, + sequence BIGINT, + default_features BOOLEAN, + + tier_name TEXT, + tier_description TEXT, + state SMALLINT, + state_description TEXT, + + audit_log_retention BIGINT, + login_policy_factors BOOLEAN, + login_policy_idp BOOLEAN, + login_policy_passwordless BOOLEAN, + login_policy_registration BOOLEAN, + login_policy_username_login BOOLEAN, + password_complexity_policy BOOLEAN, + label_policy BOOLEAN, + + PRIMARY KEY (aggregate_id) +); + +CREATE TABLE auth.features +( + aggregate_id TEXT, + + creation_date TIMESTAMPTZ, + change_date TIMESTAMPTZ, + sequence BIGINT, + default_features BOOLEAN, + + tier_name TEXT, + tier_description TEXT, + state SMALLINT, + state_description TEXT, + + audit_log_retention BIGINT, + login_policy_factors BOOLEAN, + login_policy_idp BOOLEAN, + login_policy_passwordless BOOLEAN, + login_policy_registration BOOLEAN, + login_policy_username_login BOOLEAN, + password_complexity_policy BOOLEAN, + label_policy BOOLEAN, + + PRIMARY KEY (aggregate_id) +); + +CREATE TABLE authz.features +( + aggregate_id TEXT, + + creation_date TIMESTAMPTZ, + change_date TIMESTAMPTZ, + sequence BIGINT, + default_features BOOLEAN, + + tier_name TEXT, + tier_description TEXT, + state SMALLINT, + state_description TEXT, + + audit_log_retention BIGINT, + login_policy_factors BOOLEAN, + login_policy_idp BOOLEAN, + login_policy_passwordless BOOLEAN, + login_policy_registration BOOLEAN, + login_policy_username_login BOOLEAN, + password_complexity_policy BOOLEAN, + label_policy BOOLEAN, + + PRIMARY KEY (aggregate_id) +); + +CREATE TABLE management.features +( + aggregate_id TEXT, + + creation_date TIMESTAMPTZ, + change_date TIMESTAMPTZ, + sequence BIGINT, + default_features BOOLEAN, + + tier_name TEXT, + tier_description TEXT, + state SMALLINT, + state_description TEXT, + + audit_log_retention BIGINT, + login_policy_factors BOOLEAN, + login_policy_idp BOOLEAN, + login_policy_passwordless BOOLEAN, + login_policy_registration BOOLEAN, + login_policy_username_login BOOLEAN, + password_complexity_policy BOOLEAN, + label_policy BOOLEAN, + + PRIMARY KEY (aggregate_id) +); \ No newline at end of file diff --git a/proto/zitadel/admin.proto b/proto/zitadel/admin.proto index 363e11639b..ca26452758 100644 --- a/proto/zitadel/admin.proto +++ b/proto/zitadel/admin.proto @@ -7,9 +7,11 @@ import "zitadel/options.proto"; import "zitadel/org.proto"; import "zitadel/policy.proto"; import "zitadel/member.proto"; +import "zitadel/features.proto"; import "google/api/annotations.proto"; import "google/protobuf/timestamp.proto"; +import "google/protobuf/duration.proto"; import "protoc-gen-openapiv2/options/annotations.proto"; @@ -44,7 +46,7 @@ service AdminService { get: "/healthz" }; } - + rpc IsOrgUnique(IsOrgUniqueRequest) returns (IsOrgUniqueResponse) { option (google.api.http) = { get: "/orgs/_is_unique" @@ -171,6 +173,58 @@ service AdminService { }; } + rpc GetDefaultFeatures(GetDefaultFeaturesRequest) returns (GetDefaultFeaturesResponse) { + option(google.api.http) = { + get: "/features" + }; + + option (zitadel.v1.auth_option) = { + permission: "iam.features.read" + }; + } + + rpc SetDefaultFeatures(SetDefaultFeaturesRequest) returns (SetDefaultFeaturesResponse) { + option(google.api.http) = { + put: "/features" + body: "*" + }; + + option (zitadel.v1.auth_option) = { + permission: "iam.features.write" + }; + } + + rpc GetOrgFeatures(GetOrgFeaturesRequest) returns (GetOrgFeaturesResponse) { + option(google.api.http) = { + get: "/orgs/{org_id}/features" + }; + + option (zitadel.v1.auth_option) = { + permission: "iam.features.read" + }; + } + + rpc SetOrgFeatures(SetOrgFeaturesRequest) returns (SetOrgFeaturesResponse) { + option(google.api.http) = { + put: "/orgs/{org_id}/features" + body: "*" + }; + + option (zitadel.v1.auth_option) = { + permission: "iam.features.write" + }; + } + + rpc ResetOrgFeatures(ResetOrgFeaturesRequest) returns (ResetOrgFeaturesResponse) { + option(google.api.http) = { + delete: "/orgs/{org_id}/features" + }; + + option (zitadel.v1.auth_option) = { + permission: "iam.features.write" + }; + } + rpc GetOrgIAMPolicy(GetOrgIAMPolicyRequest) returns (GetOrgIAMPolicyResponse) { option (google.api.http) = { get: "/policies/orgiam" @@ -259,18 +313,18 @@ service AdminService { option (google.api.http) = { get: "/policies/login" }; - + option (zitadel.v1.auth_option) = { permission: "iam.policy.read" }; } - + rpc UpdateLoginPolicy(UpdateLoginPolicyRequest) returns (UpdateLoginPolicyResponse) { option (google.api.http) = { put: "/policies/login" body: "*" }; - + option (zitadel.v1.auth_option) = { permission: "iam.policy.write" }; @@ -395,18 +449,18 @@ service AdminService { option (google.api.http) = { get: "/policies/password/age" }; - + option (zitadel.v1.auth_option) = { permission: "iam.policy.read" }; } - + rpc UpdatePasswordAgePolicy(UpdatePasswordAgePolicyRequest) returns (UpdatePasswordAgePolicyResponse) { option (google.api.http) = { put: "/policies/password/age" body: "*" }; - + option (zitadel.v1.auth_option) = { permission: "iam.policy.write" }; @@ -416,18 +470,18 @@ service AdminService { option (google.api.http) = { get: "/policies/password/lockout" }; - + option (zitadel.v1.auth_option) = { permission: "iam.policy.read" }; } - + rpc UpdatePasswordLockoutPolicy(UpdatePasswordLockoutPolicyRequest) returns (UpdatePasswordLockoutPolicyResponse) { option (google.api.http) = { put: "/policies/password/lockout" body: "*" }; - + option (zitadel.v1.auth_option) = { permission: "iam.policy.write" }; @@ -583,9 +637,9 @@ message SetUpOrgRequest { string phone = 1 [(validate.rules).string = {min_len: 1, max_len: 50, prefix: "+"}]; bool is_phone_verified = 2; } - + string user_name = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; - + Profile profile = 2 [(validate.rules).message.required = true]; Email email = 3 [(validate.rules).message.required = true]; Phone phone = 4; @@ -697,6 +751,66 @@ message UpdateIDPOIDCConfigResponse { zitadel.v1.ObjectDetails details = 1; } +message GetDefaultFeaturesRequest {} + +message GetDefaultFeaturesResponse { + zitadel.features.v1.Features features = 1; +} + +message SetDefaultFeaturesRequest { + string tier_name = 1 [(validate.rules).string = {max_len: 200}]; + string description = 2 [(validate.rules).string = {max_len: 200}]; + + google.protobuf.Duration audit_log_retention = 5 [(validate.rules).duration = {gte: {seconds: 0}}]; + bool login_policy_username_login = 6; + bool login_policy_registration = 7; + bool login_policy_idp = 8; + bool login_policy_factors = 9; + bool login_policy_passwordless = 10; + bool password_complexity_policy = 11; + bool label_policy = 12; +} + +message SetDefaultFeaturesResponse { + zitadel.v1.ObjectDetails details = 1; +} + +message GetOrgFeaturesRequest { + string org_id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; +} + +message GetOrgFeaturesResponse { + zitadel.features.v1.Features features = 1; +} + +message SetOrgFeaturesRequest { + string org_id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; + string tier_name = 2 [(validate.rules).string = {max_len: 200}]; + string description = 3 [(validate.rules).string = {max_len: 200}]; + zitadel.features.v1.FeaturesState state = 4; + string state_description = 5 [(validate.rules).string = {max_len: 200}]; + + google.protobuf.Duration audit_log_retention = 6 [(validate.rules).duration = {gte: {seconds: 0}}]; + bool login_policy_username_login = 7; + bool login_policy_registration = 8; + bool login_policy_idp = 9; + bool login_policy_factors = 10; + bool login_policy_passwordless = 11; + bool password_complexity_policy = 12; + bool label_policy = 13; +} + +message SetOrgFeaturesResponse { + zitadel.v1.ObjectDetails details = 1; +} + +message ResetOrgFeaturesRequest { + string org_id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; +} +message ResetOrgFeaturesResponse { + zitadel.v1.ObjectDetails details = 1; +} + message GetOrgIAMPolicyRequest {} message GetOrgIAMPolicyResponse { diff --git a/proto/zitadel/auth.proto b/proto/zitadel/auth.proto index 3fa4d35c46..280f6e51ef 100644 --- a/proto/zitadel/auth.proto +++ b/proto/zitadel/auth.proto @@ -375,6 +375,16 @@ service AuthService { }; } + rpc ListMyZitadelFeatures(ListMyZitadelFeaturesRequest) returns (ListMyZitadelFeaturesResponse) { + option (google.api.http) = { + post: "/features/zitadel/me/_search" + }; + + option (zitadel.v1.auth_option) = { + permission: "authenticated" + }; + } + rpc ListMyZitadelPermissions(ListMyZitadelPermissionsRequest) returns (ListMyZitadelPermissionsResponse) { option (google.api.http) = { post: "/permissions/zitadel/me/_search" @@ -658,6 +668,12 @@ message ListMyProjectOrgsResponse { repeated zitadel.org.v1.Org result = 2; } +message ListMyZitadelFeaturesRequest {} + +message ListMyZitadelFeaturesResponse { + repeated string result = 1; +} + message ListMyZitadelPermissionsRequest {} message ListMyZitadelPermissionsResponse { diff --git a/proto/zitadel/features.proto b/proto/zitadel/features.proto new file mode 100644 index 0000000000..1ffb024c57 --- /dev/null +++ b/proto/zitadel/features.proto @@ -0,0 +1,39 @@ +syntax = "proto3"; + +import "zitadel/object.proto"; +import "google/protobuf/timestamp.proto"; +import "google/protobuf/duration.proto"; + +package zitadel.features.v1; + +option go_package = "github.com/caos/zitadel/pkg/grpc/features"; + +message Features { + zitadel.v1.ObjectDetails details = 1; + FeatureTier tier = 2; + bool is_default = 3; + + google.protobuf.Duration audit_log_retention = 4; + bool login_policy_username_login = 5; + bool login_policy_registration = 6; + bool login_policy_idp = 7; + bool login_policy_factors = 8; + bool login_policy_passwordless = 9; + bool password_complexity_policy = 10; + bool label_policy = 11; +} + +message FeatureTier { + string name = 1; + string description = 2; + FeaturesState state = 3; + string status_info = 4; +} + + +enum FeaturesState { + FEATURES_STATE_ACTIVE = 0; + FEATURES_STATE_ACTION_REQUIRED = 1; + FEATURES_STATE_CANCELED = 2; + FEATURES_STATE_GRANDFATHERED = 3; +} \ No newline at end of file diff --git a/proto/zitadel/management.proto b/proto/zitadel/management.proto index e185bac7f2..b271dd3a13 100644 --- a/proto/zitadel/management.proto +++ b/proto/zitadel/management.proto @@ -12,6 +12,7 @@ import "zitadel/policy.proto"; import "zitadel/message.proto"; import "zitadel/change.proto"; import "zitadel/auth_n_key.proto"; +import "zitadel/features.proto"; import "google/api/annotations.proto"; import "google/protobuf/timestamp.proto"; @@ -44,7 +45,7 @@ option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = { schemes: HTTPS; consumes: "application/json"; produces: "application/json"; - + consumes: "application/grpc"; produces: "application/grpc"; @@ -242,7 +243,7 @@ service ManagementService { option (google.api.http) = { get: "/users/{user_id}/username" }; - + option (zitadel.v1.auth_option) = { permission: "user.write" }; @@ -292,7 +293,7 @@ service ManagementService { rpc ResendHumanInitialization(ResendHumanInitializationRequest) returns (ResendHumanInitializationResponse) { option (google.api.http) = { - post: "/users/{user_id}/_resend_initialization" + post: "/users/{user_id}/_resend_initialization" body: "*" }; @@ -310,7 +311,7 @@ service ManagementService { option (zitadel.v1.auth_option) = { permission: "user.write" }; - } + } rpc GetHumanPhone(GetHumanPhoneRequest) returns (GetHumanPhoneResponse) { option (google.api.http) = { @@ -1340,6 +1341,16 @@ service ManagementService { }; } + rpc GetFeatures(GetFeaturesRequest) returns (GetFeaturesResponse) { + option (google.api.http) = { + get: "/features" + }; + + option (zitadel.v1.auth_option) = { + permission: "features.read" + }; + } + rpc GetOrgIAMPolicy(GetOrgIAMPolicyRequest) returns (GetOrgIAMPolicyResponse) { option (google.api.http) = { get: "/policies/orgiam" @@ -1378,6 +1389,7 @@ service ManagementService { option (zitadel.v1.auth_option) = { permission: "policy.write" + feature: "login_policy" }; } @@ -1389,6 +1401,7 @@ service ManagementService { option (zitadel.v1.auth_option) = { permission: "policy.write" + feature: "login_policy" }; } @@ -1421,6 +1434,7 @@ service ManagementService { option (zitadel.v1.auth_option) = { permission: "policy.write" + feature: "login_policy.idp" }; } @@ -1431,6 +1445,7 @@ service ManagementService { option (zitadel.v1.auth_option) = { permission: "policy.write" + feature: "login_policy.idp" }; } @@ -1452,6 +1467,7 @@ service ManagementService { option (zitadel.v1.auth_option) = { permission: "policy.write" + feature: "login_policy.factors" }; } @@ -1462,6 +1478,7 @@ service ManagementService { option (zitadel.v1.auth_option) = { permission: "policy.write" + feature: "login_policy.factors" }; } @@ -1483,6 +1500,7 @@ service ManagementService { option (zitadel.v1.auth_option) = { permission: "policy.write" + feature: "login_policy.factors" }; } @@ -1493,6 +1511,7 @@ service ManagementService { option (zitadel.v1.auth_option) = { permission: "policy.write" + feature: "login_policy.factors" }; } @@ -1524,6 +1543,7 @@ service ManagementService { option (zitadel.v1.auth_option) = { permission: "policy.write" + feature: "password_complexity_policy" }; } @@ -1535,6 +1555,7 @@ service ManagementService { option (zitadel.v1.auth_option) = { permission: "policy.write" + feature: "password_complexity_policy" }; } @@ -1680,6 +1701,7 @@ service ManagementService { option (zitadel.v1.auth_option) = { permission: "policy.write" + feature: "label_policy" }; } @@ -1691,6 +1713,7 @@ service ManagementService { option (zitadel.v1.auth_option) = { permission: "policy.write" + feature: "label_policy" }; } @@ -1733,6 +1756,7 @@ service ManagementService { option (zitadel.v1.auth_option) = { permission: "org.idp.write" + feature: "login_policy.idp" }; } @@ -1744,6 +1768,7 @@ service ManagementService { option (zitadel.v1.auth_option) = { permission: "org.idp.write" + feature: "login_policy.idp" }; } @@ -1755,6 +1780,7 @@ service ManagementService { option (zitadel.v1.auth_option) = { permission: "org.idp.write" + feature: "login_policy.idp" }; } @@ -1765,6 +1791,7 @@ service ManagementService { option (zitadel.v1.auth_option) = { permission: "org.idp.write" + feature: "login_policy.idp" }; } @@ -1776,6 +1803,7 @@ service ManagementService { option (zitadel.v1.auth_option) = { permission: "org.idp.write" + feature: "login_policy.idp" }; } @@ -1787,6 +1815,7 @@ service ManagementService { option (zitadel.v1.auth_option) = { permission: "org.idp.write" + feature: "login_policy.idp" }; } } @@ -2956,6 +2985,12 @@ message BulkRemoveUserGrantRequest { message BulkRemoveUserGrantResponse {} +message GetFeaturesRequest {} + +message GetFeaturesResponse { + zitadel.features.v1.Features features = 1; +} + message GetOrgIAMPolicyRequest {} message GetOrgIAMPolicyResponse { diff --git a/proto/zitadel/options.proto b/proto/zitadel/options.proto index 5ca89b2f24..84e4f333d9 100644 --- a/proto/zitadel/options.proto +++ b/proto/zitadel/options.proto @@ -14,4 +14,5 @@ extend google.protobuf.MethodOptions { message AuthOption { string permission = 1; string check_field_name = 2; -} \ No newline at end of file + string feature = 3; +}