diff --git a/cmd/setup/03.go b/cmd/setup/03.go index 92e8e34c8..3037cb695 100644 --- a/cmd/setup/03.go +++ b/cmd/setup/03.go @@ -28,6 +28,7 @@ type FirstInstance struct { PatPath string LoginClientPatPath string Features *command.InstanceFeatures + TrustedDomains []string Skip bool @@ -102,6 +103,7 @@ func (mig *FirstInstance) Execute(ctx context.Context, _ eventstore.Event) error mig.instanceSetup.InstanceName = mig.InstanceName mig.instanceSetup.CustomDomain = mig.externalDomain + mig.instanceSetup.TrustedDomains = mig.TrustedDomains mig.instanceSetup.DefaultLanguage = mig.DefaultLanguage mig.instanceSetup.Org = mig.Org // check if username is email style or else append @. diff --git a/cmd/setup/steps.yaml b/cmd/setup/steps.yaml index 167367659..5b7be3523 100644 --- a/cmd/setup/steps.yaml +++ b/cmd/setup/steps.yaml @@ -121,6 +121,11 @@ FirstInstance: PatPath: # ZITADEL_FIRSTINSTANCE_PATPATH LoginClientPatPath: # ZITADEL_FIRSTINSTANCE_LOGINCLIENTPATPATH InstanceName: ZITADEL # ZITADEL_FIRSTINSTANCE_INSTANCENAME + # A list of trusted domains, which are allowed to be used for public responses like email links or when running + # ZITADEL or the login UI behind a reverse proxy. If no trusted domain is set, the instance domain is used. + TrustedDomains: # ZITADEL_FIRSTINSTANCE_TRUSTEDDOMAINS + # - login.example.com + # - api.example.com DefaultLanguage: en # ZITADEL_FIRSTINSTANCE_DEFAULTLANGUAGE Org: Name: ZITADEL # ZITADEL_FIRSTINSTANCE_ORG_NAME diff --git a/internal/command/instance.go b/internal/command/instance.go index c9bd43291..5b69ee9b5 100644 --- a/internal/command/instance.go +++ b/internal/command/instance.go @@ -40,6 +40,7 @@ type InstanceSetup struct { zitadel ZitadelConfig InstanceName string CustomDomain string + TrustedDomains []string DefaultLanguage language.Tag Org InstanceOrgSetup SecretGenerators *SecretGenerators @@ -294,6 +295,7 @@ func setUpInstance(ctx context.Context, c *Commands, setup *InstanceSetup) (vali return nil, nil, nil, nil, err } setupCustomDomain(c, &validations, instanceAgg, setup.CustomDomain) + setupTrustedDomains(c, &validations, instanceAgg, setup.TrustedDomains) // optional setting if set setupMessageTexts(&validations, setup.MessageTexts, instanceAgg) @@ -511,6 +513,14 @@ func setupGeneratedDomain(ctx context.Context, commands *Commands, validations * return nil } +func setupTrustedDomains(commands *Commands, validations *[]preparation.Validation, instanceAgg *instance.Aggregate, trustedDomains []string) { + for _, trustedDomain := range trustedDomains { + *validations = append(*validations, + commands.setupTrustedDomain(instanceAgg, trustedDomain), + ) + } +} + func setupMinimalInterfaces(commands *Commands, validations *[]preparation.Validation, instanceAgg *instance.Aggregate, orgAgg *org.Aggregate, projectOwner string, ids ZitadelConfig) { projectAgg := project.NewAggregate(ids.projectID, orgAgg.ID) diff --git a/internal/command/instance_test.go b/internal/command/instance_test.go index ca355d6fd..68fe79e33 100644 --- a/internal/command/instance_test.go +++ b/internal/command/instance_test.go @@ -365,19 +365,28 @@ func instanceElementsConfig() *SecretGenerators { } } -func setupInstanceFilters(instanceID, orgID, projectID, appID, domain string) []expect { +func setupInstanceFilters(instanceID, orgID, projectID, appID, domain string, trustedDomains []string) []expect { return slices.Concat( setupInstanceElementsFilters(instanceID), orgFilters(orgID, true, true, true), generatedDomainFilters(instanceID, orgID, projectID, appID, domain), + generatedTrustedDomainsFilters(trustedDomains), ) } -func setupInstanceEvents(ctx context.Context, instanceID, orgID, projectID, appID, instanceName, orgName string, defaultLanguage language.Tag, domain string, externalSecure bool) []eventstore.Command { +func setupInstanceEvents( + ctx context.Context, + instanceID, orgID, projectID, appID, instanceName, orgName string, + defaultLanguage language.Tag, + domain string, + externalSecure bool, + trustedDomains []string, +) []eventstore.Command { return slices.Concat( setupInstanceElementsEvents(ctx, instanceID, instanceName, defaultLanguage), orgEvents(ctx, instanceID, orgID, orgName, projectID, domain, externalSecure, true, true, true), generatedDomainEvents(ctx, instanceID, orgID, projectID, appID, domain), + generatedTrustedDomainEvents(ctx, instanceID, trustedDomains), instanceCreatedMilestoneEvent(ctx, instanceID), ) } @@ -391,6 +400,7 @@ func setupInstanceConfig() *InstanceSetup { LoginClient: instanceSetupLoginClientConfig(), } conf.CustomDomain = "" + conf.TrustedDomains = []string{"trusted-domain.com", "another-trusted-domain.com"} return conf } @@ -409,6 +419,14 @@ func generatedDomainEvents(ctx context.Context, instanceID, orgID, projectID, ap } } +func generatedTrustedDomainEvents(ctx context.Context, instanceID string, trustedDomains []string) []eventstore.Command { + commands := make([]eventstore.Command, len(trustedDomains)) + for i, trustedDomain := range trustedDomains { + commands[i] = instance.NewTrustedDomainAddedEvent(ctx, &instance.NewAggregate(instanceID).Aggregate, trustedDomain) + } + return commands +} + func instanceCreatedMilestoneEvent(ctx context.Context, instanceID string) []eventstore.Command { return []eventstore.Command{ milestone.NewReachedEvent(ctx, milestone.NewInstanceAggregate(instanceID), milestone.InstanceCreated), @@ -463,6 +481,14 @@ func generatedDomainFilters(instanceID, orgID, projectID, appID, generatedDomain } } +func generatedTrustedDomainsFilters(trustedDomains []string) []expect { + filters := make([]expect, len(trustedDomains)) + for i := range trustedDomains { + filters[i] = expectFilter() + } + return filters +} + func humanFilters(orgID string) []expect { return []expect{ expectFilter(), @@ -1324,7 +1350,7 @@ func TestCommandSide_setUpInstance(t *testing.T) { fields: fields{ eventstore: expectEventstore( slices.Concat( - setupInstanceFilters("INSTANCE", "ORG", "PROJECT", "console-id", "DOMAIN"), + setupInstanceFilters("INSTANCE", "ORG", "PROJECT", "console-id", "DOMAIN", []string{"trusted-domain.com", "another-trusted-domain.com"}), []expect{ expectPush( setupInstanceEvents(context.Background(), @@ -1337,6 +1363,7 @@ func TestCommandSide_setUpInstance(t *testing.T) { language.English, "DOMAIN", false, + []string{"trusted-domain.com", "another-trusted-domain.com"}, )..., ), }, diff --git a/internal/command/instance_trusted_domain.go b/internal/command/instance_trusted_domain.go index b1e2ed739..33f3d376d 100644 --- a/internal/command/instance_trusted_domain.go +++ b/internal/command/instance_trusted_domain.go @@ -5,21 +5,20 @@ import ( "slices" "strings" + "github.com/zitadel/zitadel/internal/command/preparation" "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/repository/instance" "github.com/zitadel/zitadel/internal/zerrors" ) -func (c *Commands) AddTrustedDomain(ctx context.Context, trustedDomain string) (*domain.ObjectDetails, error) { - trustedDomain = strings.TrimSpace(trustedDomain) - if trustedDomain == "" || len(trustedDomain) > 253 { - return nil, zerrors.ThrowInvalidArgument(nil, "COMMA-Stk21", "Errors.Invalid.Argument") - } - if !allowDomainRunes.MatchString(trustedDomain) { - return nil, zerrors.ThrowInvalidArgument(nil, "COMMA-S3v3w", "Errors.Instance.Domain.InvalidCharacter") +func (c *Commands) AddTrustedDomain(ctx context.Context, trustedDomain string) (details *domain.ObjectDetails, err error) { + trustedDomain, err = validateTrustedDomain(trustedDomain) + if err != nil { + return nil, err } model := NewInstanceTrustedDomainsWriteModel(ctx) - err := c.eventstore.FilterToQueryReducer(ctx, model) + err = c.eventstore.FilterToQueryReducer(ctx, model) if err != nil { return nil, err } @@ -33,6 +32,42 @@ func (c *Commands) AddTrustedDomain(ctx context.Context, trustedDomain string) ( return writeModelToObjectDetails(&model.WriteModel), nil } +// setupTrustedDomain is only used for the instance setup process where all events are prepared and pushed in a single transaction +func (c *Commands) setupTrustedDomain(a *instance.Aggregate, trustedDomain string) preparation.Validation { + return func() (_ preparation.CreateCommands, err error) { + trustedDomain, err = validateTrustedDomain(trustedDomain) + if err != nil { + return nil, err + } + return func(ctx context.Context, filter preparation.FilterToQueryReducer) ([]eventstore.Command, error) { + model := NewInstanceTrustedDomainsWriteModel(ctx) + events, err := filter(ctx, model.Query()) + if err != nil { + return nil, err + } + err = AppendAndReduce(model, events...) + if err != nil { + return nil, err + } + if slices.Contains(model.Domains, trustedDomain) { + return nil, zerrors.ThrowPreconditionFailed(nil, "COMMA-hg42a", "Errors.Instance.Domain.AlreadyExists") + } + return []eventstore.Command{instance.NewTrustedDomainAddedEvent(ctx, &a.Aggregate, trustedDomain)}, nil + }, nil + } +} + +func validateTrustedDomain(trustedDomain string) (string, error) { + trustedDomain = strings.TrimSpace(trustedDomain) + if trustedDomain == "" || len(trustedDomain) > 253 { + return "", zerrors.ThrowInvalidArgument(nil, "COMMA-Stk21", "Errors.Invalid.Argument") + } + if !allowDomainRunes.MatchString(trustedDomain) { + return "", zerrors.ThrowInvalidArgument(nil, "COMMA-S3v3w", "Errors.Instance.Domain.InvalidCharacter") + } + return trustedDomain, nil +} + func (c *Commands) RemoveTrustedDomain(ctx context.Context, trustedDomain string, errorIfNotFound bool) (*domain.ObjectDetails, error) { trustedDomain = strings.TrimSpace(trustedDomain) if trustedDomain == "" || len(trustedDomain) > 253 {