From c687d6769bb922a7c0a4299b64b1dfdaf7cd04c4 Mon Sep 17 00:00:00 2001 From: Oleg Lavrovsky <31819+loleg@users.noreply.github.com> Date: Tue, 7 Jan 2025 00:21:09 +0100 Subject: [PATCH 01/30] docs(adopters):Dribdat (#9021) Added a note on Zitadel support in Dribdat, which explicitly mentions it in the [install notes](https://dribdat.cc/deploy.html#authentication) and soon in a blog post or screencast. --------- Co-authored-by: Swarna Podila --- ADOPTERS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/ADOPTERS.md b/ADOPTERS.md index da37b9ffb0..0573099cf9 100644 --- a/ADOPTERS.md +++ b/ADOPTERS.md @@ -16,6 +16,7 @@ If you are using Zitadel, please consider adding yourself as a user with a quick | devOS: Sanity Edition | [@devOS-Sanity-Edition](https://github.com/devOS-Sanity-Edition) | Uses SSO Auth for every piece of our internal and external infrastructure | | CNAP.tech | [@cnap-tech](https://github.com/cnap-tech) | Using Zitadel for authentication and authorization in cloud-native applications | | Minekube | [@minekube](https://github.com/minekube) | Leveraging Zitadel for secure user authentication in gaming infrastructure | +| Dribdat | [@dribdat](https://github.com/dribdat) | Educating people about strong auth and resilient identity at hackathons | | Micromate | [@sschoeb](https://github.com/sschoeb) | Using Zitadel for authentication and authorization for learners and managers in our digital learning assistant as well as in the Micromate manage platform | | Smat.io | [@smatio](https://github.com/smatio) - [@lukasver](https://github.com/lukasver) | Zitadel for authentication in cloud applications while offering B2B portfolio management solutions for professional investors | |hirschengraben | [hirschengraben.io](hirschengraben.io) | Using Zitadel as IDP for a multitenant B2B dispatch app for bike messengers | From a54bb2977b8e8fd0a8a18cfb9e22fadaad3ab00e Mon Sep 17 00:00:00 2001 From: Florian Forster Date: Tue, 7 Jan 2025 10:12:39 +0100 Subject: [PATCH 02/30] docs: change scope for zitadel audience (#9117) # Which Problems Are Solved - This replaces the old aud claim from Zitadel in two places. # Additional Context - Relates to [this discord thread](https://discord.com/channels/927474939156643850/1305853084743766067) --- .../zitadel-apis/example-zitadel-api-with-dot-net.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/docs/guides/integrate/zitadel-apis/example-zitadel-api-with-dot-net.md b/docs/docs/guides/integrate/zitadel-apis/example-zitadel-api-with-dot-net.md index fe1b5a2f2c..7a012f799e 100644 --- a/docs/docs/guides/integrate/zitadel-apis/example-zitadel-api-with-dot-net.md +++ b/docs/docs/guides/integrate/zitadel-apis/example-zitadel-api-with-dot-net.md @@ -43,11 +43,10 @@ dotnet add package Zitadel.Api ### Create example client Change the program.cs file to the content below. This will create a client for the management api and call its `GetMyUsers` function. -The SDK will make sure you will have access to the API by retrieving a Bearer Token using JWT Profile with the provided scopes (`openid` and `urn:zitadel:iam:org:project:id:{projectID}:aud`). +The SDK will make sure you will have access to the API by retrieving a Bearer Token using JWT Profile with the provided scopes (`openid` and `urn:zitadel:iam:org:project:id:zitadel:aud`). -Make sure to fill the const `apiUrl`, `apiProject` and `personalAccessToken` with your own instance data. The used vars below are from a test instance, to show you how it should look. +Make sure to fill the const `apiUrl`, and `personalAccessToken` with your own instance data. The used vars below are from a test instance, to show you how it should look. The apiURL is the domain of your instance you can find it on the instance detail in the Customer Portal or in the Console -The apiProject you will find in the ZITADEL project in the first organization of your instance. ```csharp // This file contains two examples: @@ -66,7 +65,8 @@ var client = Clients.AuthService(new(apiUrl, ITokenProvider.Static(personalAcces var result = await client.GetMyUserAsync(new()); Console.WriteLine($"User: {result.User}"); -const string apiProject = "170078979166961921"; +// This adds the urn:zitadel:iam:org:project:id:zitadel:aud scope to the authorization request, enabling access to ZITADEL APIs. +const string apiProject = "zitadel"; var serviceAccount = ServiceAccount.LoadFromJsonString( @" { From 56427cca50cc990bd168064d55559a0af9be4cdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Tue, 7 Jan 2025 13:51:06 +0200 Subject: [PATCH 03/30] fix(cache): convert expiry to number (#9143) # Which Problems Are Solved When `LastUseAge` was configured properly, the Redis LUA script uses manual cleanup for `MaxAge` based expiry. The expiry obtained from Redis apears to be a string and was compared to an int, resulting in a script error. # How the Problems Are Solved Convert expiry to number. # Additional Changes - none # Additional Context - Introduced in #8822 - LastUseAge was fixed in #9097 - closes https://github.com/zitadel/zitadel/issues/9140 --- internal/cache/connector/redis/get.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/cache/connector/redis/get.lua b/internal/cache/connector/redis/get.lua index cfb3e89d8a..b542ff29d1 100644 --- a/internal/cache/connector/redis/get.lua +++ b/internal/cache/connector/redis/get.lua @@ -13,8 +13,8 @@ end -- max-age must be checked manually local expiry = getCall("HGET", object_id, "expiry") -if not (expiry == nil) and expiry > 0 then - if getTime() > expiry then +if not (expiry == nil) and tonumber(expiry) > 0 then + if getTime() > tonumber(expiry) then remove(object_id) return nil end From 11d36fcd0076316ec0f14da3c769235b3ed4c5fe Mon Sep 17 00:00:00 2001 From: Elio Bischof Date: Tue, 7 Jan 2025 15:38:13 +0100 Subject: [PATCH 04/30] feat(console): allow to configure PostHog (#9135) # Which Problems Are Solved The console has no information about where and how to send PostHog events. # How the Problems Are Solved A PostHog API URL and token are passed through as plain text from the Zitadel runtime config to the environment.json. By default, no values are configured and the keys in the environment.json are omitted. # Additional Context - Closes https://github.com/zitadel/zitadel/issues/9070 - Complements https://github.com/zitadel/zitadel/pull/9077 --- cmd/defaults.yaml | 3 +++ internal/api/ui/console/console.go | 12 ++++++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index e993657123..868ee06866 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -611,6 +611,9 @@ Console: # 168h is 7 days, one week SharedMaxAge: 168h # ZITADEL_CONSOLE_LONGCACHE_SHAREDMAXAGE InstanceManagementURL: "" # ZITADEL_CONSOLE_INSTANCEMANAGEMENTURL + PostHog: + URL: "" # ZITADEL_CONSOLE_POSTHOG_URL + Token: "" # ZITADEL_CONSOLE_POSTHOG_TOKEN EncryptionKeys: DomainVerification: diff --git a/internal/api/ui/console/console.go b/internal/api/ui/console/console.go index 515f26db9b..fffbc00d5b 100644 --- a/internal/api/ui/console/console.go +++ b/internal/api/ui/console/console.go @@ -28,6 +28,10 @@ type Config struct { ShortCache middleware.CacheConfig LongCache middleware.CacheConfig InstanceManagementURL string + PostHog struct { + Token string + URL string + } } type spaHandler struct { @@ -117,7 +121,7 @@ func Start(config Config, externalSecure bool, issuer op.IssuerFromRequest, call return } limited := limitingAccessInterceptor.Limit(w, r) - environmentJSON, err := createEnvironmentJSON(url, issuer(r), instance.ConsoleClientID(), customerPortal, instanceMgmtURL, limited) + environmentJSON, err := createEnvironmentJSON(url, issuer(r), instance.ConsoleClientID(), customerPortal, instanceMgmtURL, config.PostHog.URL, config.PostHog.Token, limited) if err != nil { http.Error(w, fmt.Sprintf("unable to marshal env for console: %v", err), http.StatusInternalServerError) return @@ -150,13 +154,15 @@ func csp() *middleware.CSP { return &csp } -func createEnvironmentJSON(api, issuer, clientID, customerPortal, instanceMgmtUrl string, exhausted bool) ([]byte, error) { +func createEnvironmentJSON(api, issuer, clientID, customerPortal, instanceMgmtUrl, postHogURL, postHogToken string, exhausted bool) ([]byte, error) { environment := struct { API string `json:"api,omitempty"` Issuer string `json:"issuer,omitempty"` ClientID string `json:"clientid,omitempty"` CustomerPortal string `json:"customer_portal,omitempty"` InstanceManagementURL string `json:"instance_management_url,omitempty"` + PostHogURL string `json:"posthog_url,omitempty"` + PostHogToken string `json:"posthog_token,omitempty"` Exhausted bool `json:"exhausted,omitempty"` }{ API: api, @@ -164,6 +170,8 @@ func createEnvironmentJSON(api, issuer, clientID, customerPortal, instanceMgmtUr ClientID: clientID, CustomerPortal: customerPortal, InstanceManagementURL: instanceMgmtUrl, + PostHogURL: postHogURL, + PostHogToken: postHogToken, Exhausted: exhausted, } return json.Marshal(environment) From f320d18b1a24b97b67500d471c11068fd09b6c39 Mon Sep 17 00:00:00 2001 From: Silvan <27845747+adlerhurst@users.noreply.github.com> Date: Tue, 7 Jan 2025 17:06:33 +0100 Subject: [PATCH 05/30] perf(fields): create index for instance domain query (#9146) # Which Problems Are Solved get instance by domain cannot provide an instance id because it is not known at that time. This causes a full table scan on the fields table because current indexes always include the `instance_id` column. # How the Problems Are Solved Added a specific index for this query. # Additional Context If a system has many fields and there is no cache hit for the given domain this query can heaviuly influence database CPU usage, the newly added resolves this problem. --- cmd/setup/43.go | 40 +++++++++++++++++++++++++++++++++++ cmd/setup/43/cockroach/43.sql | 3 +++ cmd/setup/43/postgres/43.sql | 3 +++ cmd/setup/config.go | 1 + cmd/setup/setup.go | 2 ++ 5 files changed, 49 insertions(+) create mode 100644 cmd/setup/43.go create mode 100644 cmd/setup/43/cockroach/43.sql create mode 100644 cmd/setup/43/postgres/43.sql diff --git a/cmd/setup/43.go b/cmd/setup/43.go new file mode 100644 index 0000000000..844c25cf24 --- /dev/null +++ b/cmd/setup/43.go @@ -0,0 +1,40 @@ +package setup + +import ( + "context" + "embed" + "fmt" + + "github.com/zitadel/logging" + + "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/eventstore" +) + +var ( + //go:embed 43/cockroach/*.sql + //go:embed 43/postgres/*.sql + createFieldsDomainIndex embed.FS +) + +type CreateFieldsDomainIndex struct { + dbClient *database.DB +} + +func (mig *CreateFieldsDomainIndex) Execute(ctx context.Context, _ eventstore.Event) error { + statements, err := readStatements(createFieldsDomainIndex, "43", mig.dbClient.Type()) + if err != nil { + return err + } + for _, stmt := range statements { + logging.WithFields("file", stmt.file, "migration", mig.String()).Info("execute statement") + if _, err := mig.dbClient.ExecContext(ctx, stmt.query); err != nil { + return fmt.Errorf("%s %s: %w", mig.String(), stmt.file, err) + } + } + return nil +} + +func (mig *CreateFieldsDomainIndex) String() string { + return "43_create_fields_domain_index" +} diff --git a/cmd/setup/43/cockroach/43.sql b/cmd/setup/43/cockroach/43.sql new file mode 100644 index 0000000000..9152130970 --- /dev/null +++ b/cmd/setup/43/cockroach/43.sql @@ -0,0 +1,3 @@ +CREATE INDEX CONCURRENTLY IF NOT EXISTS fields_instance_domains_idx +ON eventstore.fields (object_id) +WHERE object_type = 'instance_domain' AND field_name = 'domain'; \ No newline at end of file diff --git a/cmd/setup/43/postgres/43.sql b/cmd/setup/43/postgres/43.sql new file mode 100644 index 0000000000..2f6f958fdf --- /dev/null +++ b/cmd/setup/43/postgres/43.sql @@ -0,0 +1,3 @@ +CREATE INDEX CONCURRENTLY IF NOT EXISTS fields_instance_domains_idx +ON eventstore.fields (object_id) INCLUDE (instance_id) +WHERE object_type = 'instance_domain' AND field_name = 'domain'; \ No newline at end of file diff --git a/cmd/setup/config.go b/cmd/setup/config.go index ae62728c95..407a9412bb 100644 --- a/cmd/setup/config.go +++ b/cmd/setup/config.go @@ -128,6 +128,7 @@ type Steps struct { s38BackChannelLogoutNotificationStart *BackChannelLogoutNotificationStart s40InitPushFunc *InitPushFunc s42Apps7OIDCConfigsLoginVersion *Apps7OIDCConfigsLoginVersion + s43CreateFieldsDomainIndex *CreateFieldsDomainIndex } func MustNewSteps(v *viper.Viper) *Steps { diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index 497457ba8f..c803ab55b6 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -171,6 +171,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s38BackChannelLogoutNotificationStart = &BackChannelLogoutNotificationStart{dbClient: esPusherDBClient, esClient: eventstoreClient} steps.s40InitPushFunc = &InitPushFunc{dbClient: esPusherDBClient} steps.s42Apps7OIDCConfigsLoginVersion = &Apps7OIDCConfigsLoginVersion{dbClient: esPusherDBClient} + steps.s43CreateFieldsDomainIndex = &CreateFieldsDomainIndex{dbClient: queryDBClient} err = projection.Create(ctx, projectionDBClient, eventstoreClient, config.Projections, nil, nil, nil) logging.OnError(err).Fatal("unable to start projections") @@ -242,6 +243,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s33SMSConfigs3TwilioAddVerifyServiceSid, steps.s37Apps7OIDConfigsBackChannelLogoutURI, steps.s42Apps7OIDCConfigsLoginVersion, + steps.s43CreateFieldsDomainIndex, } { mustExecuteMigration(ctx, eventstoreClient, step, "migration failed") } From 8d8f38fb4ca6e767f266ceffa07244c9c55448c2 Mon Sep 17 00:00:00 2001 From: Stefan Benz <46600784+stebenz@users.noreply.github.com> Date: Tue, 7 Jan 2025 17:34:59 +0100 Subject: [PATCH 06/30] fix: only allowed idps in login step (#9136) # Which Problems Are Solved If a not allowed IDP is selected or now not allowed IDP was selected before at login, the login will still try to use it as fallback. The same goes for the linked IDPs which are not necessarily active anymore, or disallowed through policies. # How the Problems Are Solved Check all possible or configured IDPs if they can be used. # Additional Changes None # Additional Context Addition to #6466 --------- Co-authored-by: Livio Spring --- .../eventsourcing/eventstore/auth_request.go | 41 +++- .../eventstore/auth_request_test.go | 226 +++++++++++++++++- 2 files changed, 259 insertions(+), 8 deletions(-) diff --git a/internal/auth/repository/eventsourcing/eventstore/auth_request.go b/internal/auth/repository/eventsourcing/eventstore/auth_request.go index e35e7b5143..813c5668f4 100644 --- a/internal/auth/repository/eventsourcing/eventstore/auth_request.go +++ b/internal/auth/repository/eventsourcing/eventstore/auth_request.go @@ -1065,8 +1065,10 @@ func (repo *AuthRequestRepo) nextSteps(ctx context.Context, request *domain.Auth return nil, err } noLocalAuth := request.LoginPolicy != nil && !request.LoginPolicy.AllowUsernamePassword - if (!isInternalLogin || len(idps.Links) > 0 || noLocalAuth) && len(request.LinkingUsers) == 0 { - step, err := repo.idpChecked(request, idps.Links, userSession) + + allowedLinkedIDPs := checkForAllowedIDPs(request.AllowedExternalIDPs, idps.Links) + if (!isInternalLogin || len(allowedLinkedIDPs) > 0 || noLocalAuth) && len(request.LinkingUsers) == 0 { + step, err := repo.idpChecked(request, allowedLinkedIDPs, userSession) if err != nil { return nil, err } @@ -1146,6 +1148,19 @@ func (repo *AuthRequestRepo) nextSteps(ctx context.Context, request *domain.Auth return append(steps, &domain.RedirectToCallbackStep{}), nil } +func checkForAllowedIDPs(allowedIDPs []*domain.IDPProvider, idps []*query.IDPUserLink) (_ []string) { + allowedLinkedIDPs := make([]string, 0, len(idps)) + // only use allowed linked idps + for _, idp := range idps { + for _, allowedIdP := range allowedIDPs { + if idp.IDPID == allowedIdP.IDPConfigID { + allowedLinkedIDPs = append(allowedLinkedIDPs, allowedIdP.IDPConfigID) + } + } + } + return allowedLinkedIDPs +} + func passwordAgeChangeRequired(policy *domain.PasswordAgePolicy, changed time.Time) bool { if policy == nil || policy.MaxAgeDays == 0 { return false @@ -1299,7 +1314,7 @@ func (repo *AuthRequestRepo) firstFactorChecked(ctx context.Context, request *do return &domain.PasswordStep{} } -func (repo *AuthRequestRepo) idpChecked(request *domain.AuthRequest, idps []*query.IDPUserLink, userSession *user_model.UserSessionView) (domain.NextStep, error) { +func (repo *AuthRequestRepo) idpChecked(request *domain.AuthRequest, idps []string, userSession *user_model.UserSessionView) (domain.NextStep, error) { if checkVerificationTimeMaxAge(userSession.ExternalLoginVerification, request.LoginPolicy.ExternalLoginCheckLifetime, request) { request.IDPLoginChecked = true request.AuthTime = userSession.ExternalLoginVerification @@ -1307,15 +1322,27 @@ func (repo *AuthRequestRepo) idpChecked(request *domain.AuthRequest, idps []*que } // use the explicitly set IdP first if request.SelectedIDPConfigID != "" { - return &domain.ExternalLoginStep{SelectedIDPConfigID: request.SelectedIDPConfigID}, nil + // only use the explicitly set IdP if allowed + for _, allowedIdP := range request.AllowedExternalIDPs { + if request.SelectedIDPConfigID == allowedIdP.IDPConfigID { + return &domain.ExternalLoginStep{SelectedIDPConfigID: request.SelectedIDPConfigID}, nil + } + } + // error if the explicitly set IdP is not allowed, to avoid misinterpretation with usage of another IdP + return nil, zerrors.ThrowPreconditionFailed(nil, "LOGIN-LWif2", "Errors.Org.IdpNotExisting") } // reuse the previously used IdP from the session if userSession.SelectedIDPConfigID != "" { - return &domain.ExternalLoginStep{SelectedIDPConfigID: userSession.SelectedIDPConfigID}, nil + // only use the previously used IdP if allowed + for _, allowedIdP := range request.AllowedExternalIDPs { + if userSession.SelectedIDPConfigID == allowedIdP.IDPConfigID { + return &domain.ExternalLoginStep{SelectedIDPConfigID: userSession.SelectedIDPConfigID}, nil + } + } } - // then use an existing linked IdP of the user + // then use an existing linked and allowed IdP of the user if len(idps) > 0 { - return &domain.ExternalLoginStep{SelectedIDPConfigID: idps[0].IDPID}, nil + return &domain.ExternalLoginStep{SelectedIDPConfigID: idps[0]}, nil } // if the user did not link one, then just use one of the configured IdPs of the org if len(request.AllowedExternalIDPs) > 0 { diff --git a/internal/auth/repository/eventsourcing/eventstore/auth_request_test.go b/internal/auth/repository/eventsourcing/eventstore/auth_request_test.go index dda8c54872..976ae8d8a9 100644 --- a/internal/auth/repository/eventsourcing/eventstore/auth_request_test.go +++ b/internal/auth/repository/eventsourcing/eventstore/auth_request_test.go @@ -1247,6 +1247,7 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { args{&domain.AuthRequest{ UserID: "UserID", SelectedIDPConfigID: "IDPConfigID", + AllowedExternalIDPs: []*domain.IDPProvider{{IDPConfigID: "IDPConfigID"}}, LoginPolicy: &domain.LoginPolicy{ AllowUsernamePassword: false, SecondFactorCheckLifetime: 18 * time.Hour, @@ -1254,6 +1255,193 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { []domain.NextStep{&domain.ExternalLoginStep{SelectedIDPConfigID: "IDPConfigID"}}, nil, }, + { + "external user (idp selected, not allowed, no external verification), error", + fields{ + userSessionViewProvider: &mockViewUserSession{ + SecondFactorVerification: testNow.Add(-5 * time.Minute), + }, + userViewProvider: &mockViewUser{ + IsEmailVerified: true, + MFAMaxSetUp: int32(domain.MFALevelSecondFactor), + }, + userEventProvider: &mockEventUser{}, + lockoutPolicyProvider: &mockLockoutPolicy{ + policy: &query.LockoutPolicy{ + ShowFailures: true, + }, + }, + orgViewProvider: &mockViewOrg{State: domain.OrgStateActive}, + loginPolicyProvider: &mockLoginPolicy{ + policy: &query.LoginPolicy{ + SecondFactorCheckLifetime: database.Duration(18 * time.Hour), + }, + }, + idpUserLinksProvider: &mockIDPUserLinks{}, + }, + args{&domain.AuthRequest{ + UserID: "UserID", + SelectedIDPConfigID: "IDPConfigID", + AllowedExternalIDPs: []*domain.IDPProvider{}, + LoginPolicy: &domain.LoginPolicy{ + AllowUsernamePassword: false, + SecondFactorCheckLifetime: 18 * time.Hour, + }}, false}, + nil, + zerrors.IsPreconditionFailed, + }, + { + "external user (idp link, no external verification), external login step", + fields{ + userSessionViewProvider: &mockViewUserSession{ + SecondFactorVerification: testNow.Add(-5 * time.Minute), + }, + userViewProvider: &mockViewUser{ + IsEmailVerified: true, + MFAMaxSetUp: int32(domain.MFALevelSecondFactor), + }, + userEventProvider: &mockEventUser{}, + lockoutPolicyProvider: &mockLockoutPolicy{ + policy: &query.LockoutPolicy{ + ShowFailures: true, + }, + }, + orgViewProvider: &mockViewOrg{State: domain.OrgStateActive}, + loginPolicyProvider: &mockLoginPolicy{ + policy: &query.LoginPolicy{ + SecondFactorCheckLifetime: database.Duration(18 * time.Hour), + }, + }, + idpUserLinksProvider: &mockIDPUserLinks{ + []*query.IDPUserLink{ + {IDPID: "IDPConfigID"}, + }, + }, + }, + args{&domain.AuthRequest{ + UserID: "UserID", + SelectedIDPConfigID: "", + AllowedExternalIDPs: []*domain.IDPProvider{{IDPConfigID: "IDPConfigID"}}, + LoginPolicy: &domain.LoginPolicy{ + AllowUsernamePassword: false, + SecondFactorCheckLifetime: 18 * time.Hour, + }}, false}, + []domain.NextStep{&domain.ExternalLoginStep{SelectedIDPConfigID: "IDPConfigID"}}, + nil, + }, + { + "external user (idp link not allowed, no external verification), external login step", + fields{ + userSessionViewProvider: &mockViewUserSession{ + SecondFactorVerification: testNow.Add(-5 * time.Minute), + }, + userViewProvider: &mockViewUser{ + IsEmailVerified: true, + MFAMaxSetUp: int32(domain.MFALevelSecondFactor), + }, + userEventProvider: &mockEventUser{}, + lockoutPolicyProvider: &mockLockoutPolicy{ + policy: &query.LockoutPolicy{ + ShowFailures: true, + }, + }, + orgViewProvider: &mockViewOrg{State: domain.OrgStateActive}, + loginPolicyProvider: &mockLoginPolicy{ + policy: &query.LoginPolicy{ + SecondFactorCheckLifetime: database.Duration(18 * time.Hour), + }, + }, + idpUserLinksProvider: &mockIDPUserLinks{ + []*query.IDPUserLink{ + {IDPID: "IDPConfigID1"}, + }, + }, + }, + args{&domain.AuthRequest{ + UserID: "UserID", + SelectedIDPConfigID: "", + AllowedExternalIDPs: []*domain.IDPProvider{{IDPConfigID: "IDPConfigID2"}}, + LoginPolicy: &domain.LoginPolicy{ + AllowUsernamePassword: false, + SecondFactorCheckLifetime: 18 * time.Hour, + }}, false}, + []domain.NextStep{&domain.ExternalLoginStep{SelectedIDPConfigID: "IDPConfigID2"}}, + nil, + }, + { + "external user (idp link not allowed, none allowed, no external verification), external login step", + fields{ + userSessionViewProvider: &mockViewUserSession{ + SecondFactorVerification: testNow.Add(-5 * time.Minute), + }, + userViewProvider: &mockViewUser{ + IsEmailVerified: true, + MFAMaxSetUp: int32(domain.MFALevelSecondFactor), + }, + userEventProvider: &mockEventUser{}, + lockoutPolicyProvider: &mockLockoutPolicy{ + policy: &query.LockoutPolicy{ + ShowFailures: true, + }, + }, + orgViewProvider: &mockViewOrg{State: domain.OrgStateActive}, + loginPolicyProvider: &mockLoginPolicy{ + policy: &query.LoginPolicy{ + SecondFactorCheckLifetime: database.Duration(18 * time.Hour), + }, + }, + idpUserLinksProvider: &mockIDPUserLinks{ + []*query.IDPUserLink{ + {IDPID: "IDPConfigID1"}, + }, + }, + }, + args{&domain.AuthRequest{ + UserID: "UserID", + SelectedIDPConfigID: "", + AllowedExternalIDPs: []*domain.IDPProvider{}, + LoginPolicy: &domain.LoginPolicy{ + AllowUsernamePassword: false, + SecondFactorCheckLifetime: 18 * time.Hour, + }}, false}, + nil, + zerrors.IsPreconditionFailed, + }, + { + "external user (no idp allowed, no external verification), error", + fields{ + userSessionViewProvider: &mockViewUserSession{ + SecondFactorVerification: testNow.Add(-5 * time.Minute), + }, + userViewProvider: &mockViewUser{ + IsEmailVerified: true, + MFAMaxSetUp: int32(domain.MFALevelSecondFactor), + }, + userEventProvider: &mockEventUser{}, + lockoutPolicyProvider: &mockLockoutPolicy{ + policy: &query.LockoutPolicy{ + ShowFailures: true, + }, + }, + orgViewProvider: &mockViewOrg{State: domain.OrgStateActive}, + loginPolicyProvider: &mockLoginPolicy{ + policy: &query.LoginPolicy{ + SecondFactorCheckLifetime: database.Duration(18 * time.Hour), + }, + }, + idpUserLinksProvider: &mockIDPUserLinks{}, + }, + args{&domain.AuthRequest{ + UserID: "UserID", + SelectedIDPConfigID: "", + AllowedExternalIDPs: []*domain.IDPProvider{}, + LoginPolicy: &domain.LoginPolicy{ + AllowUsernamePassword: false, + SecondFactorCheckLifetime: 18 * time.Hour, + }}, false}, + nil, + zerrors.IsPreconditionFailed, + }, { "external user (only idp available, no external verification), external login step", fields{ @@ -1281,13 +1469,49 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { }, }, args{&domain.AuthRequest{ - UserID: "UserID", + UserID: "UserID", + AllowedExternalIDPs: []*domain.IDPProvider{{IDPConfigID: "IDPConfigID"}}, LoginPolicy: &domain.LoginPolicy{ AllowUsernamePassword: false, SecondFactorCheckLifetime: 18 * time.Hour, }}, false}, []domain.NextStep{&domain.ExternalLoginStep{SelectedIDPConfigID: "IDPConfigID"}}, nil, + }, { + "external user (only idp available, no allowed, no external verification), external login step", + fields{ + userSessionViewProvider: &mockViewUserSession{ + SecondFactorVerification: testNow.Add(-5 * time.Minute), + }, + userViewProvider: &mockViewUser{ + IsEmailVerified: true, + MFAMaxSetUp: int32(domain.MFALevelSecondFactor), + }, + userEventProvider: &mockEventUser{}, + lockoutPolicyProvider: &mockLockoutPolicy{ + policy: &query.LockoutPolicy{ + ShowFailures: true, + }, + }, + orgViewProvider: &mockViewOrg{State: domain.OrgStateActive}, + loginPolicyProvider: &mockLoginPolicy{ + policy: &query.LoginPolicy{ + SecondFactorCheckLifetime: database.Duration(18 * time.Hour), + }, + }, + idpUserLinksProvider: &mockIDPUserLinks{ + idps: []*query.IDPUserLink{{IDPID: "IDPConfigID"}}, + }, + }, + args{&domain.AuthRequest{ + UserID: "UserID", + AllowedExternalIDPs: []*domain.IDPProvider{}, + LoginPolicy: &domain.LoginPolicy{ + AllowUsernamePassword: false, + SecondFactorCheckLifetime: 18 * time.Hour, + }}, false}, + nil, + zerrors.IsPreconditionFailed, }, { "external user (external verification set), callback", From 42cc6dce79bb0659956d358d142a5ecf2cac59f3 Mon Sep 17 00:00:00 2001 From: Alexey Morozov Date: Tue, 7 Jan 2025 23:32:19 +0300 Subject: [PATCH 07/30] fix(i18n): typo in Russian login description (#9100) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Which Problems Are Solved Typo in RU localization on login page. # How the Problems Are Solved Fixed typo by replacing to correct text. # Additional Changes n/a # Additional Context n/a Co-authored-by: Tim Möhlmann --- internal/api/ui/login/static/i18n/ru.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/api/ui/login/static/i18n/ru.yaml b/internal/api/ui/login/static/i18n/ru.yaml index 03239e0612..221c20a2e9 100644 --- a/internal/api/ui/login/static/i18n/ru.yaml +++ b/internal/api/ui/login/static/i18n/ru.yaml @@ -1,6 +1,6 @@ Login: Title: Добро пожаловать! - Description: Введите свои данные дял входа. + Description: Введите свои данные для входа. TitleLinking: Вход для привязки пользователей DescriptionLinking: Введите данные для входа, чтобы привязать внешнего пользователя к учётной записи ZITADEL. LoginNameLabel: Логин From db8d794794eba191d1b1f3a79ea5b4ec2c90a821 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Wed, 8 Jan 2025 10:40:33 +0200 Subject: [PATCH 08/30] fix(oidc): ignore algorithm for legacy signer (#9148) # Which Problems Are Solved It was possible to set a diffent algorithm for the legacy signer. This is not supported howerver and breaks the token endpoint. # How the Problems Are Solved Remove the OIDC.SigningKeyAlgorithm config option and hard-code RS256 for the legacy signer. # Additional Changes - none # Additional Context Only RS256 is supported by the legacy signer. It was mentioned in the comment of the config not to use it and use the webkeys resource instead. - closes #9121 --- cmd/defaults.yaml | 3 --- internal/api/oidc/key.go | 10 +++++----- internal/api/oidc/op.go | 4 ---- 3 files changed, 5 insertions(+), 12 deletions(-) diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index 868ee06866..e376e8a488 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -530,9 +530,6 @@ OIDC: GrantTypeRefreshToken: true # ZITADEL_OIDC_GRANTTYPEREFRESHTOKEN RequestObjectSupported: true # ZITADEL_OIDC_REQUESTOBJECTSUPPORTED - # Deprecated: The signing algorithm is determined by the generated keys. - # Use the web keys resource to generate keys with different algorithms. - SigningKeyAlgorithm: RS256 # ZITADEL_OIDC_SIGNINGKEYALGORITHM # Sets the default values for lifetime and expiration for OIDC # This default can be overwritten in the default instance configuration and for each instance during runtime # !!! Changing this after the initial setup will have no impact without a restart !!! diff --git a/internal/api/oidc/key.go b/internal/api/oidc/key.go index 535aa846b4..6c0599f556 100644 --- a/internal/api/oidc/key.go +++ b/internal/api/oidc/key.go @@ -354,15 +354,15 @@ func (o *OPStorage) getSigningKey(ctx context.Context) (op.SigningKey, error) { if keys.State != nil { position = keys.State.Position } - return nil, o.refreshSigningKey(ctx, o.signingKeyAlgorithm, position) + return nil, o.refreshSigningKey(ctx, position) } -func (o *OPStorage) refreshSigningKey(ctx context.Context, algorithm string, position float64) error { +func (o *OPStorage) refreshSigningKey(ctx context.Context, position float64) error { ok, err := o.ensureIsLatestKey(ctx, position) if err != nil || !ok { return zerrors.ThrowInternal(err, "OIDC-ASfh3", "cannot ensure that projection is up to date") } - err = o.lockAndGenerateSigningKeyPair(ctx, algorithm) + err = o.lockAndGenerateSigningKeyPair(ctx) if err != nil { return zerrors.ThrowInternal(err, "OIDC-ADh31", "could not create signing key") } @@ -393,7 +393,7 @@ func PrivateKeyToSigningKey(key query.PrivateKey, algorithm crypto.EncryptionAlg }, nil } -func (o *OPStorage) lockAndGenerateSigningKeyPair(ctx context.Context, algorithm string) error { +func (o *OPStorage) lockAndGenerateSigningKeyPair(ctx context.Context) error { logging.Info("lock and generate signing key pair") ctx, cancel := context.WithCancel(ctx) @@ -409,7 +409,7 @@ func (o *OPStorage) lockAndGenerateSigningKeyPair(ctx context.Context, algorithm return err } - return o.command.GenerateSigningKeyPair(setOIDCCtx(ctx), algorithm) + return o.command.GenerateSigningKeyPair(setOIDCCtx(ctx), "RS256") } func (o *OPStorage) getMaxKeySequence(ctx context.Context) (float64, error) { diff --git a/internal/api/oidc/op.go b/internal/api/oidc/op.go index 86b89690bf..153a13f06e 100644 --- a/internal/api/oidc/op.go +++ b/internal/api/oidc/op.go @@ -31,7 +31,6 @@ type Config struct { AuthMethodPrivateKeyJWT bool GrantTypeRefreshToken bool RequestObjectSupported bool - SigningKeyAlgorithm string DefaultAccessTokenLifetime time.Duration DefaultIdTokenLifetime time.Duration DefaultRefreshTokenIdleExpiration time.Duration @@ -71,7 +70,6 @@ type OPStorage struct { defaultLogoutURLV2 string defaultAccessTokenLifetime time.Duration defaultIdTokenLifetime time.Duration - signingKeyAlgorithm string defaultRefreshTokenIdleExpiration time.Duration defaultRefreshTokenExpiration time.Duration encAlg crypto.EncryptionAlgorithm @@ -162,7 +160,6 @@ func NewServer( jwksCacheControlMaxAge: config.JWKSCacheControlMaxAge, fallbackLogger: fallbackLogger, hasher: hasher, - signingKeyAlgorithm: config.SigningKeyAlgorithm, encAlg: encryptionAlg, opCrypto: op.NewAESCrypto(opConfig.CryptoKey), assetAPIPrefix: assets.AssetAPI(), @@ -232,7 +229,6 @@ func newStorage(config Config, command *command.Commands, query *query.Queries, defaultLoginURL: fmt.Sprintf("%s%s?%s=", login.HandlerPrefix, login.EndpointLogin, login.QueryAuthRequestID), defaultLoginURLV2: config.DefaultLoginURLV2, defaultLogoutURLV2: config.DefaultLogoutURLV2, - signingKeyAlgorithm: config.SigningKeyAlgorithm, defaultAccessTokenLifetime: config.DefaultAccessTokenLifetime, defaultIdTokenLifetime: config.DefaultIdTokenLifetime, defaultRefreshTokenIdleExpiration: config.DefaultRefreshTokenIdleExpiration, From c966446f803aacfc03fbc0c152e11dbe34e9d64e Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Wed, 8 Jan 2025 10:30:12 +0100 Subject: [PATCH 09/30] fix: correctly get x-forwarded-for for browser info in events (#9149) # Which Problems Are Solved Events like "password check succeeded" store some information about the caller including their IP. The `X-Forwarded-For` was not correctly logged, but instead the RemoteAddress. # How the Problems Are Solved - Correctly get the `X-Forwarded-For` in canonical form. # Additional Changes None # Additional Context closes [#9106](https://github.com/zitadel/zitadel/issues/9106) --- internal/api/http/header.go | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/internal/api/http/header.go b/internal/api/http/header.go index 16ae7cf48c..982684c77c 100644 --- a/internal/api/http/header.go +++ b/internal/api/http/header.go @@ -108,14 +108,8 @@ func GetOrgID(r *http.Request) string { } func GetForwardedFor(headers http.Header) (string, bool) { - forwarded, ok := headers[ForwardedFor] - if ok { - ip := strings.TrimSpace(strings.Split(forwarded[0], ",")[0]) - if ip != "" { - return ip, true - } - } - return "", false + forwarded := strings.Split(headers.Get(ForwardedFor), ",")[0] + return forwarded, forwarded != "" } func RemoteAddrFromCtx(ctx context.Context) string { From df2c6f1d4c23fe43c6a78d911f65f82dd4594ecf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Wed, 8 Jan 2025 13:59:44 +0200 Subject: [PATCH 10/30] perf(eventstore): optimize commands to events function (#9092) # Which Problems Are Solved We were seeing high query costs in a the lateral join executed in the commands_to_events procedural function in the database. The high cost resulted in incremental CPU usage as a load test continued and less req/sec handled, sarting at 836 and ending at 130 req/sec. # How the Problems Are Solved 1. Set `PARALLEL SAFE`. I noticed that this option defaults to `UNSAFE`. But it's actually safe if the function doesn't `INSERT` 2. Set the returned `ROWS 10` parameter. 3. Function is re-written in Pl/PgSQL so that we eliminate expensive joins. 4. Introduced an intermediate state that does `SELECT DISTINCT` for the aggregate so that we don't have to do an expensive lateral join. # Additional Changes Use a `COALESCE` to get the owner from the last event, instead of a `CASE` switch. # Additional Context - Function was introduced in https://github.com/zitadel/zitadel/pull/8816 - Closes https://github.com/zitadel/zitadel/issues/8352 --------- Co-authored-by: Silvan <27845747+adlerhurst@users.noreply.github.com> --- cmd/setup/40.go | 2 +- cmd/setup/40/postgres/02_func.sql | 148 ++++++++++++++++-------------- 2 files changed, 80 insertions(+), 70 deletions(-) diff --git a/cmd/setup/40.go b/cmd/setup/40.go index a0d1afcf54..0a3a116d21 100644 --- a/cmd/setup/40.go +++ b/cmd/setup/40.go @@ -48,5 +48,5 @@ func (mig *InitPushFunc) Execute(ctx context.Context, _ eventstore.Event) (err e } func (mig *InitPushFunc) String() string { - return "40_init_push_func" + return "40_init_push_func_v2" } diff --git a/cmd/setup/40/postgres/02_func.sql b/cmd/setup/40/postgres/02_func.sql index 5f84f3908c..0d566ebb42 100644 --- a/cmd/setup/40/postgres/02_func.sql +++ b/cmd/setup/40/postgres/02_func.sql @@ -1,82 +1,92 @@ -CREATE OR REPLACE FUNCTION eventstore.commands_to_events(commands eventstore.command[]) RETURNS SETOF eventstore.events2 VOLATILE AS $$ -SELECT - c.instance_id - , c.aggregate_type - , c.aggregate_id - , c.command_type AS event_type - , cs.sequence + ROW_NUMBER() OVER (PARTITION BY c.instance_id, c.aggregate_type, c.aggregate_id ORDER BY c.in_tx_order) AS sequence - , c.revision - , NOW() AS created_at - , c.payload - , c.creator - , cs.owner - , EXTRACT(EPOCH FROM NOW()) AS position - , c.in_tx_order -FROM ( - SELECT - c.instance_id - , c.aggregate_type - , c.aggregate_id - , c.command_type - , c.revision - , c.payload - , c.creator - , c.owner - , ROW_NUMBER() OVER () AS in_tx_order - FROM - UNNEST(commands) AS c -) AS c -JOIN ( - SELECT - cmds.instance_id - , cmds.aggregate_type - , cmds.aggregate_id - , CASE WHEN (e.owner IS NOT NULL OR e.owner <> '') THEN e.owner ELSE command_owners.owner END AS owner - , COALESCE(MAX(e.sequence), 0) AS sequence - FROM ( +CREATE OR REPLACE FUNCTION eventstore.latest_aggregate_state( + instance_id TEXT + , aggregate_type TEXT + , aggregate_id TEXT + + , sequence OUT BIGINT + , owner OUT TEXT +) + LANGUAGE 'plpgsql' + STABLE PARALLEL SAFE +AS $$ + BEGIN + SELECT + COALESCE(e.sequence, 0) AS sequence + , e.owner + INTO + sequence + , owner + FROM + eventstore.events2 e + WHERE + e.instance_id = $1 + AND e.aggregate_type = $2 + AND e.aggregate_id = $3 + ORDER BY + e.sequence DESC + LIMIT 1; + + RETURN; + END; +$$; + +CREATE OR REPLACE FUNCTION eventstore.commands_to_events(commands eventstore.command[]) + RETURNS SETOF eventstore.events2 + LANGUAGE 'plpgsql' + STABLE PARALLEL SAFE + ROWS 10 +AS $$ +DECLARE + "aggregate" RECORD; + current_sequence BIGINT; + current_owner TEXT; +BEGIN + FOR "aggregate" IN SELECT DISTINCT instance_id , aggregate_type , aggregate_id - , owner FROM UNNEST(commands) - ) AS cmds - LEFT JOIN eventstore.events2 AS e - ON cmds.instance_id = e.instance_id - AND cmds.aggregate_type = e.aggregate_type - AND cmds.aggregate_id = e.aggregate_id - JOIN ( + LOOP + SELECT + * + INTO + current_sequence + , current_owner + FROM eventstore.latest_aggregate_state( + "aggregate".instance_id + , "aggregate".aggregate_type + , "aggregate".aggregate_id + ); + + RETURN QUERY SELECT - DISTINCT ON ( - instance_id - , aggregate_type - , aggregate_id - ) - instance_id - , aggregate_type - , aggregate_id - , owner + c.instance_id + , c.aggregate_type + , c.aggregate_id + , c.command_type -- AS event_type + , COALESCE(current_sequence, 0) + ROW_NUMBER() OVER () -- AS sequence + , c.revision + , NOW() -- AS created_at + , c.payload + , c.creator + , COALESCE(current_owner, c.owner) -- AS owner + , EXTRACT(EPOCH FROM NOW()) -- AS position + , c.ordinality::INT -- AS in_tx_order FROM - UNNEST(commands) - ) AS command_owners ON - cmds.instance_id = command_owners.instance_id - AND cmds.aggregate_type = command_owners.aggregate_type - AND cmds.aggregate_id = command_owners.aggregate_id - GROUP BY - cmds.instance_id - , cmds.aggregate_type - , cmds.aggregate_id - , 4 -- owner -) AS cs - ON c.instance_id = cs.instance_id - AND c.aggregate_type = cs.aggregate_type - AND c.aggregate_id = cs.aggregate_id -ORDER BY - in_tx_order; -$$ LANGUAGE SQL; + UNNEST(commands) WITH ORDINALITY AS c + WHERE + c.instance_id = aggregate.instance_id + AND c.aggregate_type = aggregate.aggregate_type + AND c.aggregate_id = aggregate.aggregate_id; + END LOOP; + RETURN; +END; +$$; CREATE OR REPLACE FUNCTION eventstore.push(commands eventstore.command[]) RETURNS SETOF eventstore.events2 VOLATILE AS $$ INSERT INTO eventstore.events2 SELECT * FROM eventstore.commands_to_events(commands) +ORDER BY in_tx_order RETURNING * $$ LANGUAGE SQL; From 829f4543da19831cd80f0c4b618a9382aa36b2cf Mon Sep 17 00:00:00 2001 From: Silvan <27845747+adlerhurst@users.noreply.github.com> Date: Wed, 8 Jan 2025 17:54:17 +0100 Subject: [PATCH 11/30] perf(eventstore): redefine current sequences index (#9142) # Which Problems Are Solved On Zitadel cloud we found changing the order of columns in the `eventstore.events2_current_sequence` index improved CPU usage for the `SELECT ... FOR UPDATE` query the pusher executes. # How the Problems Are Solved `eventstore.events2_current_sequence`-index got replaced # Additional Context closes https://github.com/zitadel/zitadel/issues/9082 --- cmd/setup/44.go | 39 ++++++++++++++++++++++++++++++ cmd/setup/44/01_create_index.sql | 3 +++ cmd/setup/44/02_drop_old_index.sql | 1 + cmd/setup/config.go | 1 + cmd/setup/setup.go | 2 ++ 5 files changed, 46 insertions(+) create mode 100644 cmd/setup/44.go create mode 100644 cmd/setup/44/01_create_index.sql create mode 100644 cmd/setup/44/02_drop_old_index.sql diff --git a/cmd/setup/44.go b/cmd/setup/44.go new file mode 100644 index 0000000000..11c355a053 --- /dev/null +++ b/cmd/setup/44.go @@ -0,0 +1,39 @@ +package setup + +import ( + "context" + "embed" + "fmt" + + "github.com/zitadel/logging" + + "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/eventstore" +) + +var ( + //go:embed 44/*.sql + replaceCurrentSequencesIndex embed.FS +) + +type ReplaceCurrentSequencesIndex struct { + dbClient *database.DB +} + +func (mig *ReplaceCurrentSequencesIndex) Execute(ctx context.Context, _ eventstore.Event) error { + statements, err := readStatements(replaceCurrentSequencesIndex, "44", "") + if err != nil { + return err + } + for _, stmt := range statements { + logging.WithFields("file", stmt.file, "migration", mig.String()).Info("execute statement") + if _, err := mig.dbClient.ExecContext(ctx, stmt.query); err != nil { + return fmt.Errorf("%s %s: %w", mig.String(), stmt.file, err) + } + } + return nil +} + +func (mig *ReplaceCurrentSequencesIndex) String() string { + return "44_replace_current_sequences_index" +} diff --git a/cmd/setup/44/01_create_index.sql b/cmd/setup/44/01_create_index.sql new file mode 100644 index 0000000000..105d5b76b6 --- /dev/null +++ b/cmd/setup/44/01_create_index.sql @@ -0,0 +1,3 @@ +CREATE INDEX CONCURRENTLY IF NOT EXISTS events2_current_sequence2 + ON eventstore.events2 USING btree + (aggregate_id ASC, aggregate_type ASC, instance_id ASC, sequence DESC); diff --git a/cmd/setup/44/02_drop_old_index.sql b/cmd/setup/44/02_drop_old_index.sql new file mode 100644 index 0000000000..cf97ff9fc3 --- /dev/null +++ b/cmd/setup/44/02_drop_old_index.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS eventstore.events2_current_sequence; \ No newline at end of file diff --git a/cmd/setup/config.go b/cmd/setup/config.go index 407a9412bb..9f34c2baa5 100644 --- a/cmd/setup/config.go +++ b/cmd/setup/config.go @@ -129,6 +129,7 @@ type Steps struct { s40InitPushFunc *InitPushFunc s42Apps7OIDCConfigsLoginVersion *Apps7OIDCConfigsLoginVersion s43CreateFieldsDomainIndex *CreateFieldsDomainIndex + s44ReplaceCurrentSequencesIndex *ReplaceCurrentSequencesIndex } func MustNewSteps(v *viper.Viper) *Steps { diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index c803ab55b6..4ffef441af 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -172,6 +172,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s40InitPushFunc = &InitPushFunc{dbClient: esPusherDBClient} steps.s42Apps7OIDCConfigsLoginVersion = &Apps7OIDCConfigsLoginVersion{dbClient: esPusherDBClient} steps.s43CreateFieldsDomainIndex = &CreateFieldsDomainIndex{dbClient: queryDBClient} + steps.s44ReplaceCurrentSequencesIndex = &ReplaceCurrentSequencesIndex{dbClient: esPusherDBClient} err = projection.Create(ctx, projectionDBClient, eventstoreClient, config.Projections, nil, nil, nil) logging.OnError(err).Fatal("unable to start projections") @@ -225,6 +226,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s35AddPositionToIndexEsWm, steps.s36FillV2Milestones, steps.s38BackChannelLogoutNotificationStart, + steps.s44ReplaceCurrentSequencesIndex, } { mustExecuteMigration(ctx, eventstoreClient, step, "migration failed") } From e621224ab2a9dd94be4f02505f0b5c45c9dc3f79 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 9 Jan 2025 12:46:36 +0100 Subject: [PATCH 12/30] feat: create user scim v2 endpoint (#9132) # Which Problems Are Solved - Adds infrastructure code (basic implementation, error handling, middlewares, ...) to implement the SCIM v2 interface - Adds support for the user create SCIM v2 endpoint # How the Problems Are Solved - Adds support for the user create SCIM v2 endpoint under `POST /scim/v2/{orgID}/Users` # Additional Context Part of #8140 --- cmd/defaults.yaml | 5 + cmd/start/config.go | 2 + cmd/start/start.go | 13 + internal/api/http/header.go | 12 + .../api/http/middleware/auth_interceptor.go | 62 ++++- internal/api/http/middleware/handler.go | 26 ++ .../http/middleware/instance_interceptor.go | 52 ++-- .../middleware/instance_interceptor_test.go | 92 +++++-- internal/api/scim/authz.go | 13 + internal/api/scim/config/config.go | 6 + .../api/scim/integration_test/scim_test.go | 28 ++ .../testdata/users_create_test_full.json | 116 ++++++++ .../users_create_test_invalid_locale.json | 17 ++ .../users_create_test_invalid_password.json | 17 ++ ...users_create_test_invalid_profile_url.json | 17 ++ .../users_create_test_invalid_timezone.json | 17 ++ .../testdata/users_create_test_minimal.json | 16 ++ .../users_create_test_missing_email.json | 10 + .../users_create_test_missing_name.json | 15 ++ .../users_create_test_missing_username.json | 15 ++ .../integration_test/users_create_test.go | 250 ++++++++++++++++++ internal/api/scim/metadata/context.go | 23 ++ internal/api/scim/metadata/metadata.go | 60 +++++ .../middleware/content_type_middleware.go | 53 ++++ .../content_type_middleware_test.go | 107 ++++++++ .../middleware/scim_context_middleware.go | 54 ++++ .../api/scim/resources/resource_handler.go | 61 +++++ .../resources/resource_handler_adapter.go | 69 +++++ internal/api/scim/resources/user.go | 146 ++++++++++ internal/api/scim/resources/user_mapping.go | 81 ++++++ internal/api/scim/resources/user_metadata.go | 150 +++++++++++ internal/api/scim/schemas/schemas.go | 20 ++ internal/api/scim/schemas/string.go | 28 ++ internal/api/scim/schemas/string_test.go | 70 +++++ internal/api/scim/schemas/url.go | 50 ++++ internal/api/scim/schemas/url_test.go | 182 +++++++++++++ internal/api/scim/serrors/errors.go | 140 ++++++++++ internal/api/scim/serrors/errors_test.go | 110 ++++++++ internal/api/scim/server.go | 74 ++++++ internal/integration/assert.go | 15 ++ internal/integration/client.go | 3 + internal/integration/scim/client.go | 133 ++++++++++ internal/zerrors/zerror.go | 5 + internal/zerrors/zerror_test.go | 25 ++ 44 files changed, 2412 insertions(+), 48 deletions(-) create mode 100644 internal/api/http/middleware/handler.go create mode 100644 internal/api/scim/authz.go create mode 100644 internal/api/scim/config/config.go create mode 100644 internal/api/scim/integration_test/scim_test.go create mode 100644 internal/api/scim/integration_test/testdata/users_create_test_full.json create mode 100644 internal/api/scim/integration_test/testdata/users_create_test_invalid_locale.json create mode 100644 internal/api/scim/integration_test/testdata/users_create_test_invalid_password.json create mode 100644 internal/api/scim/integration_test/testdata/users_create_test_invalid_profile_url.json create mode 100644 internal/api/scim/integration_test/testdata/users_create_test_invalid_timezone.json create mode 100644 internal/api/scim/integration_test/testdata/users_create_test_minimal.json create mode 100644 internal/api/scim/integration_test/testdata/users_create_test_missing_email.json create mode 100644 internal/api/scim/integration_test/testdata/users_create_test_missing_name.json create mode 100644 internal/api/scim/integration_test/testdata/users_create_test_missing_username.json create mode 100644 internal/api/scim/integration_test/users_create_test.go create mode 100644 internal/api/scim/metadata/context.go create mode 100644 internal/api/scim/metadata/metadata.go create mode 100644 internal/api/scim/middleware/content_type_middleware.go create mode 100644 internal/api/scim/middleware/content_type_middleware_test.go create mode 100644 internal/api/scim/middleware/scim_context_middleware.go create mode 100644 internal/api/scim/resources/resource_handler.go create mode 100644 internal/api/scim/resources/resource_handler_adapter.go create mode 100644 internal/api/scim/resources/user.go create mode 100644 internal/api/scim/resources/user_mapping.go create mode 100644 internal/api/scim/resources/user_metadata.go create mode 100644 internal/api/scim/schemas/schemas.go create mode 100644 internal/api/scim/schemas/string.go create mode 100644 internal/api/scim/schemas/string_test.go create mode 100644 internal/api/scim/schemas/url.go create mode 100644 internal/api/scim/schemas/url_test.go create mode 100644 internal/api/scim/serrors/errors.go create mode 100644 internal/api/scim/serrors/errors_test.go create mode 100644 internal/api/scim/server.go create mode 100644 internal/integration/scim/client.go diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index e376e8a488..74ffffafcd 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -590,6 +590,11 @@ SAML: # Company: ZITADEL # ZITADEL_SAML_PROVIDERCONFIG_CONTACTPERSON_COMPANY # EmailAddress: hi@zitadel.com # ZITADEL_SAML_PROVIDERCONFIG_CONTACTPERSON_EMAILADDRESS +SCIM: + # default values whether an email/phone is considered verified when a users email/phone is created or updated + EmailVerified: true # ZITADEL_SCIM_EMAILVERIFIED + PhoneVerified: true # ZITADEL_SCIM_PHONEVERIFIED + Login: LanguageCookieName: zitadel.login.lang # ZITADEL_LOGIN_LANGUAGECOOKIENAME CSRFCookieName: zitadel.login.csrf # ZITADEL_LOGIN_CSRFCOOKIENAME diff --git a/cmd/start/config.go b/cmd/start/config.go index 6182342592..d63b8a319a 100644 --- a/cmd/start/config.go +++ b/cmd/start/config.go @@ -15,6 +15,7 @@ import ( "github.com/zitadel/zitadel/internal/api/http/middleware" "github.com/zitadel/zitadel/internal/api/oidc" "github.com/zitadel/zitadel/internal/api/saml" + scim_config "github.com/zitadel/zitadel/internal/api/scim/config" "github.com/zitadel/zitadel/internal/api/ui/console" "github.com/zitadel/zitadel/internal/api/ui/login" auth_es "github.com/zitadel/zitadel/internal/auth/repository/eventsourcing" @@ -60,6 +61,7 @@ type Config struct { UserAgentCookie *middleware.UserAgentCookieConfig OIDC oidc.Config SAML saml.Config + SCIM scim_config.Config Login login.Config Console console.Config AssetStorage static_config.AssetStorageConfig diff --git a/cmd/start/start.go b/cmd/start/start.go index 154c683481..21f445cfd6 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -63,6 +63,8 @@ import ( "github.com/zitadel/zitadel/internal/api/oidc" "github.com/zitadel/zitadel/internal/api/robots_txt" "github.com/zitadel/zitadel/internal/api/saml" + "github.com/zitadel/zitadel/internal/api/scim" + "github.com/zitadel/zitadel/internal/api/scim/schemas" "github.com/zitadel/zitadel/internal/api/ui/console" "github.com/zitadel/zitadel/internal/api/ui/console/path" "github.com/zitadel/zitadel/internal/api/ui/login" @@ -519,6 +521,17 @@ func startAPIs( } apis.RegisterHandlerOnPrefix(saml.HandlerPrefix, samlProvider.HttpHandler()) + apis.RegisterHandlerOnPrefix( + schemas.HandlerPrefix, + scim.NewServer( + commands, + queries, + verifier, + keys.User, + &config.SCIM, + instanceInterceptor.HandlerFuncWithError, + middleware.AuthorizationInterceptor(verifier, config.InternalAuthZ).HandlerFuncWithError)) + c, err := console.Start(config.Console, config.ExternalSecure, oidcServer.IssuerFromRequest, middleware.CallDurationHandler, instanceInterceptor.Handler, limitingAccessInterceptor, config.CustomerPortal) if err != nil { return nil, fmt.Errorf("unable to start console: %w", err) diff --git a/internal/api/http/header.go b/internal/api/http/header.go index 982684c77c..a6c2818728 100644 --- a/internal/api/http/header.go +++ b/internal/api/http/header.go @@ -5,6 +5,8 @@ import ( "net" "net/http" "strings" + + "github.com/gorilla/mux" ) const ( @@ -14,6 +16,7 @@ const ( CacheControl = "cache-control" ContentType = "content-type" ContentLength = "content-length" + ContentLocation = "content-location" Expires = "expires" Location = "location" Origin = "origin" @@ -42,6 +45,9 @@ const ( PermissionsPolicy = "permissions-policy" ZitadelOrgID = "x-zitadel-orgid" + + OrgIdInPathVariableName = "orgId" + OrgIdInPathVariable = "{" + OrgIdInPathVariableName + "}" ) type key int @@ -104,6 +110,12 @@ func GetAuthorization(r *http.Request) string { } func GetOrgID(r *http.Request) string { + // path variable takes precedence over header + orgID, ok := mux.Vars(r)[OrgIdInPathVariableName] + if ok { + return orgID + } + return r.Header.Get(ZitadelOrgID) } diff --git a/internal/api/http/middleware/auth_interceptor.go b/internal/api/http/middleware/auth_interceptor.go index c327d8c846..1581d401b4 100644 --- a/internal/api/http/middleware/auth_interceptor.go +++ b/internal/api/http/middleware/auth_interceptor.go @@ -2,12 +2,15 @@ package middleware import ( "context" - "errors" "net/http" + "strings" + + "github.com/gorilla/mux" "github.com/zitadel/zitadel/internal/api/authz" http_util "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/telemetry/tracing" + "github.com/zitadel/zitadel/internal/zerrors" ) type AuthInterceptor struct { @@ -23,34 +26,40 @@ func AuthorizationInterceptor(verifier authz.APITokenVerifier, authConfig authz. } func (a *AuthInterceptor) Handler(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ctx, err := authorize(r, a.verifier, a.authConfig) - if err != nil { - http.Error(w, err.Error(), http.StatusUnauthorized) - return - } - r = r.WithContext(ctx) - next.ServeHTTP(w, r) - }) + return a.HandlerFunc(next) } -func (a *AuthInterceptor) HandlerFunc(next http.HandlerFunc) http.HandlerFunc { +func (a *AuthInterceptor) HandlerFunc(next http.Handler) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx, err := authorize(r, a.verifier, a.authConfig) if err != nil { http.Error(w, err.Error(), http.StatusUnauthorized) return } + r = r.WithContext(ctx) next.ServeHTTP(w, r) } } +func (a *AuthInterceptor) HandlerFuncWithError(next HandlerFuncWithError) HandlerFuncWithError { + return func(w http.ResponseWriter, r *http.Request) error { + ctx, err := authorize(r, a.verifier, a.authConfig) + if err != nil { + return err + } + + r = r.WithContext(ctx) + return next(w, r) + } +} + type httpReq struct{} func authorize(r *http.Request, verifier authz.APITokenVerifier, authConfig authz.Config) (_ context.Context, err error) { ctx := r.Context() - authOpt, needsToken := verifier.CheckAuthMethod(r.Method + ":" + r.RequestURI) + + authOpt, needsToken := checkAuthMethod(r, verifier) if !needsToken { return ctx, nil } @@ -59,7 +68,7 @@ func authorize(r *http.Request, verifier authz.APITokenVerifier, authConfig auth authToken := http_util.GetAuthorization(r) if authToken == "" { - return nil, errors.New("auth header missing") + return nil, zerrors.ThrowUnauthenticated(nil, "AUT-1179", "auth header missing") } ctxSetter, err := authz.CheckUserAuthorization(authCtx, &httpReq{}, authToken, http_util.GetOrgID(r), "", verifier, authConfig, authOpt, r.RequestURI) @@ -69,3 +78,30 @@ func authorize(r *http.Request, verifier authz.APITokenVerifier, authConfig auth span.End() return ctxSetter(ctx), nil } + +func checkAuthMethod(r *http.Request, verifier authz.APITokenVerifier) (authz.Option, bool) { + authOpt, needsToken := verifier.CheckAuthMethod(r.Method + ":" + r.RequestURI) + if needsToken { + return authOpt, true + } + + route := mux.CurrentRoute(r) + if route == nil { + return authOpt, false + } + + pathTemplate, err := route.GetPathTemplate() + if err != nil || pathTemplate == "" { + return authOpt, false + } + + // the path prefix is usually handled in a router in upper layer + // trim the query and the path of the url to get the correct path prefix + pathPrefix := r.RequestURI + if i := strings.Index(pathPrefix, "?"); i != -1 { + pathPrefix = pathPrefix[0:i] + } + pathPrefix = strings.TrimSuffix(pathPrefix, r.URL.Path) + + return verifier.CheckAuthMethod(r.Method + ":" + pathPrefix + pathTemplate) +} diff --git a/internal/api/http/middleware/handler.go b/internal/api/http/middleware/handler.go new file mode 100644 index 0000000000..2c79b6227a --- /dev/null +++ b/internal/api/http/middleware/handler.go @@ -0,0 +1,26 @@ +package middleware + +import "net/http" + +// HandlerFuncWithError is a http handler func which can return an error +// the error should then get handled later on in the pipeline by an error handler +// the error handler can be dependent on the interface standard (e.g. SCIM, Problem Details, ...) +type HandlerFuncWithError = func(w http.ResponseWriter, r *http.Request) error + +// MiddlewareWithErrorFunc is a http middleware which can return an error +// the error should then get handled later on in the pipeline by an error handler +// the error handler can be dependent on the interface standard (e.g. SCIM, Problem Details, ...) +type MiddlewareWithErrorFunc = func(HandlerFuncWithError) HandlerFuncWithError + +// ErrorHandlerFunc handles errors and returns a regular http handler +type ErrorHandlerFunc = func(HandlerFuncWithError) http.Handler + +func ChainedWithErrorHandler(errorHandler ErrorHandlerFunc, middlewares ...MiddlewareWithErrorFunc) func(HandlerFuncWithError) http.Handler { + return func(next HandlerFuncWithError) http.Handler { + for i := len(middlewares) - 1; i >= 0; i-- { + next = middlewares[i](next) + } + + return errorHandler(next) + } +} diff --git a/internal/api/http/middleware/instance_interceptor.go b/internal/api/http/middleware/instance_interceptor.go index facb2ceec0..3ae5dfbb88 100644 --- a/internal/api/http/middleware/instance_interceptor.go +++ b/internal/api/http/middleware/instance_interceptor.go @@ -34,43 +34,57 @@ func InstanceInterceptor(verifier authz.InstanceVerifier, externalDomain string, } func (a *instanceInterceptor) Handler(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - a.handleInstance(w, r, next) - }) + return a.HandlerFunc(next) } -func (a *instanceInterceptor) HandlerFunc(next http.HandlerFunc) http.HandlerFunc { +func (a *instanceInterceptor) HandlerFunc(next http.Handler) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - a.handleInstance(w, r, next) - } -} - -func (a *instanceInterceptor) handleInstance(w http.ResponseWriter, r *http.Request, next http.Handler) { - for _, prefix := range a.ignoredPrefixes { - if strings.HasPrefix(r.URL.Path, prefix) { + ctx, err := a.setInstanceIfNeeded(r.Context(), r) + if err == nil { + r = r.WithContext(ctx) next.ServeHTTP(w, r) return } - } - ctx, err := setInstance(r, a.verifier) - if err != nil { + origin := zitadel_http.DomainContext(r.Context()) logging.WithFields("origin", origin.Origin(), "externalDomain", a.externalDomain).WithError(err).Error("unable to set instance") + zErr := new(zerrors.ZitadelError) if errors.As(err, &zErr) { zErr.SetMessage(a.translator.LocalizeFromRequest(r, zErr.GetMessage(), nil)) http.Error(w, fmt.Sprintf("unable to set instance using origin %s (ExternalDomain is %s): %s", origin, a.externalDomain, zErr), http.StatusNotFound) return } + http.Error(w, fmt.Sprintf("unable to set instance using origin %s (ExternalDomain is %s)", origin, a.externalDomain), http.StatusNotFound) - return } - r = r.WithContext(ctx) - next.ServeHTTP(w, r) } -func setInstance(r *http.Request, verifier authz.InstanceVerifier) (_ context.Context, err error) { - ctx := r.Context() +func (a *instanceInterceptor) HandlerFuncWithError(next HandlerFuncWithError) HandlerFuncWithError { + return func(w http.ResponseWriter, r *http.Request) error { + ctx, err := a.setInstanceIfNeeded(r.Context(), r) + if err != nil { + origin := zitadel_http.DomainContext(r.Context()) + logging.WithFields("origin", origin.Origin(), "externalDomain", a.externalDomain).WithError(err).Error("unable to set instance") + return err + } + + r = r.WithContext(ctx) + return next(w, r) + } +} + +func (a *instanceInterceptor) setInstanceIfNeeded(ctx context.Context, r *http.Request) (context.Context, error) { + for _, prefix := range a.ignoredPrefixes { + if strings.HasPrefix(r.URL.Path, prefix) { + return ctx, nil + } + } + + return setInstance(ctx, a.verifier) +} + +func setInstance(ctx context.Context, verifier authz.InstanceVerifier) (_ context.Context, err error) { authCtx, span := tracing.NewServerInterceptorSpan(ctx) defer func() { span.EndWithError(err) }() diff --git a/internal/api/http/middleware/instance_interceptor_test.go b/internal/api/http/middleware/instance_interceptor_test.go index 51c0fb9a10..da831dff65 100644 --- a/internal/api/http/middleware/instance_interceptor_test.go +++ b/internal/api/http/middleware/instance_interceptor_test.go @@ -72,7 +72,7 @@ func Test_instanceInterceptor_Handler(t *testing.T) { translator: newZitadelTranslator(), } next := &testHandler{} - got := a.HandlerFunc(next.ServeHTTP) + got := a.HandlerFunc(next) rr := httptest.NewRecorder() got.ServeHTTP(rr, tt.args.request) assert.Equal(t, tt.res.statusCode, rr.Code) @@ -136,7 +136,7 @@ func Test_instanceInterceptor_HandlerFunc(t *testing.T) { translator: newZitadelTranslator(), } next := &testHandler{} - got := a.HandlerFunc(next.ServeHTTP) + got := a.HandlerFunc(next) rr := httptest.NewRecorder() got.ServeHTTP(rr, tt.args.request) assert.Equal(t, tt.res.statusCode, rr.Code) @@ -145,9 +145,78 @@ func Test_instanceInterceptor_HandlerFunc(t *testing.T) { } } +func Test_instanceInterceptor_HandlerFuncWithError(t *testing.T) { + type fields struct { + verifier authz.InstanceVerifier + } + type args struct { + request *http.Request + } + type res struct { + wantErr bool + context context.Context + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + "setInstance error", + fields{ + verifier: &mockInstanceVerifier{}, + }, + args{ + request: httptest.NewRequest("", "/url", nil), + }, + res{ + wantErr: true, + context: nil, + }, + }, + { + "setInstance ok", + fields{ + verifier: &mockInstanceVerifier{instanceHost: "host"}, + }, + args{ + request: func() *http.Request { + r := httptest.NewRequest("", "/url", nil) + r = r.WithContext(zitadel_http.WithDomainContext(r.Context(), &zitadel_http.DomainCtx{InstanceHost: "host"})) + return r + }(), + }, + res{ + context: authz.WithInstance(zitadel_http.WithDomainContext(context.Background(), &zitadel_http.DomainCtx{InstanceHost: "host"}), &mockInstance{}), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := &instanceInterceptor{ + verifier: tt.fields.verifier, + translator: newZitadelTranslator(), + } + var ctx context.Context + got := a.HandlerFuncWithError(func(w http.ResponseWriter, r *http.Request) error { + ctx = r.Context() + return nil + }) + rr := httptest.NewRecorder() + err := got(rr, tt.args.request) + if (err != nil) != tt.res.wantErr { + t.Errorf("got error %v, want %v", err, tt.res.wantErr) + } + + assert.Equal(t, tt.res.context, ctx) + }) + } +} + func Test_setInstance(t *testing.T) { type args struct { - r *http.Request + ctx context.Context verifier authz.InstanceVerifier } type res struct { @@ -162,10 +231,7 @@ func Test_setInstance(t *testing.T) { { "no domain context, not found error", args{ - r: func() *http.Request { - r := httptest.NewRequest("", "/url", nil) - return r - }(), + ctx: context.Background(), verifier: &mockInstanceVerifier{}, }, res{ @@ -176,10 +242,7 @@ func Test_setInstance(t *testing.T) { { "instanceHost found, ok", args{ - r: func() *http.Request { - r := httptest.NewRequest("", "/url", nil) - return r.WithContext(zitadel_http.WithDomainContext(r.Context(), &zitadel_http.DomainCtx{InstanceHost: "host", Protocol: "https"})) - }(), + ctx: zitadel_http.WithDomainContext(context.Background(), &zitadel_http.DomainCtx{InstanceHost: "host", Protocol: "https"}), verifier: &mockInstanceVerifier{instanceHost: "host"}, }, res{ @@ -190,10 +253,7 @@ func Test_setInstance(t *testing.T) { { "instanceHost not found, error", args{ - r: func() *http.Request { - r := httptest.NewRequest("", "/url", nil) - return r.WithContext(zitadel_http.WithDomainContext(r.Context(), &zitadel_http.DomainCtx{InstanceHost: "fromorigin:9999", Protocol: "https"})) - }(), + ctx: zitadel_http.WithDomainContext(context.Background(), &zitadel_http.DomainCtx{InstanceHost: "fromorigin:9999", Protocol: "https"}), verifier: &mockInstanceVerifier{instanceHost: "unknowndomain"}, }, res{ @@ -204,7 +264,7 @@ func Test_setInstance(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := setInstance(tt.args.r, tt.args.verifier) + got, err := setInstance(tt.args.ctx, tt.args.verifier) if (err != nil) != tt.res.err { t.Errorf("setInstance() error = %v, wantErr %v", err, tt.res.err) return diff --git a/internal/api/scim/authz.go b/internal/api/scim/authz.go new file mode 100644 index 0000000000..759ee3e84d --- /dev/null +++ b/internal/api/scim/authz.go @@ -0,0 +1,13 @@ +package scim + +import ( + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/api/http" + "github.com/zitadel/zitadel/internal/domain" +) + +var AuthMapping = authz.MethodMapping{ + "POST:/scim/v2/" + http.OrgIdInPathVariable + "/Users": { + Permission: domain.PermissionUserWrite, + }, +} diff --git a/internal/api/scim/config/config.go b/internal/api/scim/config/config.go new file mode 100644 index 0000000000..6199f0a2ea --- /dev/null +++ b/internal/api/scim/config/config.go @@ -0,0 +1,6 @@ +package config + +type Config struct { + EmailVerified bool + PhoneVerified bool +} diff --git a/internal/api/scim/integration_test/scim_test.go b/internal/api/scim/integration_test/scim_test.go new file mode 100644 index 0000000000..e722ffdb18 --- /dev/null +++ b/internal/api/scim/integration_test/scim_test.go @@ -0,0 +1,28 @@ +//go:build integration + +package integration_test + +import ( + "context" + "github.com/zitadel/zitadel/internal/integration" + "os" + "testing" + "time" +) + +var ( + Instance *integration.Instance + CTX context.Context +) + +func TestMain(m *testing.M) { + os.Exit(func() int { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + Instance = integration.NewInstance(ctx) + + CTX = Instance.WithAuthorization(ctx, integration.UserTypeOrgOwner) + return m.Run() + }()) +} diff --git a/internal/api/scim/integration_test/testdata/users_create_test_full.json b/internal/api/scim/integration_test/testdata/users_create_test_full.json new file mode 100644 index 0000000000..7879ecf160 --- /dev/null +++ b/internal/api/scim/integration_test/testdata/users_create_test_full.json @@ -0,0 +1,116 @@ +{ + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "externalId": "701984", + "userName": "bjensen@example.com", + "name": { + "formatted": "Ms. Barbara J Jensen, III", + "familyName": "Jensen", + "givenName": "Barbara", + "middleName": "Jane", + "honorificPrefix": "Ms.", + "honorificSuffix": "III" + }, + "displayName": "Babs Jensen", + "nickName": "Babs", + "profileUrl": "http://login.example.com/bjensen", + "emails": [ + { + "value": "bjensen@example.com", + "type": "work", + "primary": true + }, + { + "value": "babs@jensen.org", + "type": "home" + } + ], + "addresses": [ + { + "type": "work", + "streetAddress": "100 Universal City Plaza", + "locality": "Hollywood", + "region": "CA", + "postalCode": "91608", + "country": "USA", + "formatted": "100 Universal City Plaza\nHollywood, CA 91608 USA", + "primary": true + }, + { + "type": "home", + "streetAddress": "456 Hollywood Blvd", + "locality": "Hollywood", + "region": "CA", + "postalCode": "91608", + "country": "USA", + "formatted": "456 Hollywood Blvd\nHollywood, CA 91608 USA" + } + ], + "phoneNumbers": [ + { + "value": "555-555-5555", + "type": "work", + "primary": true + }, + { + "value": "555-555-4444", + "type": "mobile" + } + ], + "ims": [ + { + "value": "someaimhandle", + "type": "aim" + }, + { + "value": "twitterhandle", + "type": "X" + } + ], + "photos": [ + { + "value": + "https://photos.example.com/profilephoto/72930000000Ccne/F", + "type": "photo" + }, + { + "value": + "https://photos.example.com/profilephoto/72930000000Ccne/T", + "type": "thumbnail" + } + ], + "roles": [ + { + "value": "my-role-1", + "display": "Rolle 1", + "type": "main-role", + "primary": true + }, + { + "value": "my-role-2", + "display": "Rolle 2", + "type": "secondary-role", + "primary": false + } + ], + "entitlements": [ + { + "value": "my-entitlement-1", + "display": "Entitlement 1", + "type": "main-entitlement", + "primary": true + }, + { + "value": "my-entitlement-2", + "display": "Entitlement 2", + "type": "secondary-entitlement", + "primary": false + } + ], + "userType": "Employee", + "title": "Tour Guide", + "preferredLanguage": "en-US", + "locale": "en-US", + "timezone": "America/Los_Angeles", + "active":true, + "password": "Password1!" +} \ No newline at end of file diff --git a/internal/api/scim/integration_test/testdata/users_create_test_invalid_locale.json b/internal/api/scim/integration_test/testdata/users_create_test_invalid_locale.json new file mode 100644 index 0000000000..eaadac8b90 --- /dev/null +++ b/internal/api/scim/integration_test/testdata/users_create_test_invalid_locale.json @@ -0,0 +1,17 @@ +{ + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:User" + ], + "userName": "acmeUser1", + "name": { + "familyName": "Ross", + "givenName": "Bethany" + }, + "emails": [ + { + "value": "user1@example.com", + "primary": true + } + ], + "locale": "fooBar" +} \ No newline at end of file diff --git a/internal/api/scim/integration_test/testdata/users_create_test_invalid_password.json b/internal/api/scim/integration_test/testdata/users_create_test_invalid_password.json new file mode 100644 index 0000000000..7a3d71cbed --- /dev/null +++ b/internal/api/scim/integration_test/testdata/users_create_test_invalid_password.json @@ -0,0 +1,17 @@ +{ + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:User" + ], + "userName": "acmeUser1", + "name": { + "familyName": "Ross", + "givenName": "Bethany" + }, + "emails": [ + { + "value": "user1@example.com", + "primary": true + } + ], + "password": "fooBar" +} \ No newline at end of file diff --git a/internal/api/scim/integration_test/testdata/users_create_test_invalid_profile_url.json b/internal/api/scim/integration_test/testdata/users_create_test_invalid_profile_url.json new file mode 100644 index 0000000000..3bc8fee87b --- /dev/null +++ b/internal/api/scim/integration_test/testdata/users_create_test_invalid_profile_url.json @@ -0,0 +1,17 @@ +{ + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:User" + ], + "userName": "acmeUser1", + "name": { + "familyName": "Ross", + "givenName": "Bethany" + }, + "emails": [ + { + "value": "user1@example.com", + "primary": true + } + ], + "profileUrl": "ftp://login.example.com/bjensen" +} \ No newline at end of file diff --git a/internal/api/scim/integration_test/testdata/users_create_test_invalid_timezone.json b/internal/api/scim/integration_test/testdata/users_create_test_invalid_timezone.json new file mode 100644 index 0000000000..d4ac9aa0a5 --- /dev/null +++ b/internal/api/scim/integration_test/testdata/users_create_test_invalid_timezone.json @@ -0,0 +1,17 @@ +{ + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:User" + ], + "userName": "acmeUser1", + "name": { + "familyName": "Ross", + "givenName": "Bethany" + }, + "emails": [ + { + "value": "user1@example.com", + "primary": true + } + ], + "timezone": "fooBar" +} \ No newline at end of file diff --git a/internal/api/scim/integration_test/testdata/users_create_test_minimal.json b/internal/api/scim/integration_test/testdata/users_create_test_minimal.json new file mode 100644 index 0000000000..c51f416bc7 --- /dev/null +++ b/internal/api/scim/integration_test/testdata/users_create_test_minimal.json @@ -0,0 +1,16 @@ +{ + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:User" + ], + "userName": "acmeUser1", + "name": { + "familyName": "Ross", + "givenName": "Bethany" + }, + "emails": [ + { + "value": "user1@example.com", + "primary": true + } + ] +} \ No newline at end of file diff --git a/internal/api/scim/integration_test/testdata/users_create_test_missing_email.json b/internal/api/scim/integration_test/testdata/users_create_test_missing_email.json new file mode 100644 index 0000000000..c68ebf98a0 --- /dev/null +++ b/internal/api/scim/integration_test/testdata/users_create_test_missing_email.json @@ -0,0 +1,10 @@ +{ + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:User" + ], + "userName": "acmeUser1", + "name": { + "familyName": "Ross", + "givenName": "Bethany" + } +} \ No newline at end of file diff --git a/internal/api/scim/integration_test/testdata/users_create_test_missing_name.json b/internal/api/scim/integration_test/testdata/users_create_test_missing_name.json new file mode 100644 index 0000000000..d1d3375f89 --- /dev/null +++ b/internal/api/scim/integration_test/testdata/users_create_test_missing_name.json @@ -0,0 +1,15 @@ +{ + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:User" + ], + "userName": "acmeUser1", + "name": { + "givenName": "Bethany" + }, + "emails": [ + { + "value": "user1@example.com", + "primary": true + } + ] +} \ No newline at end of file diff --git a/internal/api/scim/integration_test/testdata/users_create_test_missing_username.json b/internal/api/scim/integration_test/testdata/users_create_test_missing_username.json new file mode 100644 index 0000000000..9446665226 --- /dev/null +++ b/internal/api/scim/integration_test/testdata/users_create_test_missing_username.json @@ -0,0 +1,15 @@ +{ + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:User" + ], + "name": { + "familyName": "Ross", + "givenName": "Bethany" + }, + "emails": [ + { + "value": "user1@example.com", + "primary": true + } + ] +} \ No newline at end of file diff --git a/internal/api/scim/integration_test/users_create_test.go b/internal/api/scim/integration_test/users_create_test.go new file mode 100644 index 0000000000..cad0fcbd33 --- /dev/null +++ b/internal/api/scim/integration_test/users_create_test.go @@ -0,0 +1,250 @@ +//go:build integration + +package integration_test + +import ( + "context" + _ "embed" + "errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zitadel/zitadel/internal/api/scim/schemas" + "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/internal/integration/scim" + "github.com/zitadel/zitadel/pkg/grpc/management" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" + "google.golang.org/grpc/codes" + "net/http" + "path" + "strconv" + "testing" +) + +var ( + //go:embed testdata/users_create_test_minimal.json + minimalUserJson []byte + + //go:embed testdata/users_create_test_full.json + fullUserJson []byte + + //go:embed testdata/users_create_test_missing_username.json + missingUserNameUserJson []byte + + //go:embed testdata/users_create_test_missing_name.json + missingNameUserJson []byte + + //go:embed testdata/users_create_test_missing_email.json + missingEmailUserJson []byte + + //go:embed testdata/users_create_test_invalid_password.json + invalidPasswordUserJson []byte + + //go:embed testdata/users_create_test_invalid_profile_url.json + invalidProfileUrlUserJson []byte + + //go:embed testdata/users_create_test_invalid_locale.json + invalidLocaleUserJson []byte + + //go:embed testdata/users_create_test_invalid_timezone.json + invalidTimeZoneUserJson []byte +) + +func TestCreateUser(t *testing.T) { + tests := []struct { + name string + body []byte + ctx context.Context + wantErr bool + scimErrorType string + errorStatus int + zitadelErrID string + }{ + { + name: "minimal user", + body: minimalUserJson, + }, + { + name: "full user", + body: fullUserJson, + }, + { + name: "missing userName", + wantErr: true, + scimErrorType: "invalidValue", + body: missingUserNameUserJson, + }, + { + // this is an expected schema violation + name: "missing name", + wantErr: true, + scimErrorType: "invalidValue", + body: missingNameUserJson, + }, + { + name: "missing email", + wantErr: true, + scimErrorType: "invalidValue", + body: missingEmailUserJson, + }, + { + name: "password complexity violation", + wantErr: true, + scimErrorType: "invalidValue", + body: invalidPasswordUserJson, + }, + { + name: "invalid profile url", + wantErr: true, + scimErrorType: "invalidValue", + zitadelErrID: "SCIM-htturl1", + body: invalidProfileUrlUserJson, + }, + { + name: "invalid time zone", + wantErr: true, + scimErrorType: "invalidValue", + body: invalidTimeZoneUserJson, + }, + { + name: "invalid locale", + wantErr: true, + scimErrorType: "invalidValue", + body: invalidLocaleUserJson, + }, + { + name: "not authenticated", + body: minimalUserJson, + ctx: context.Background(), + wantErr: true, + errorStatus: http.StatusUnauthorized, + }, + { + name: "no permissions", + body: minimalUserJson, + ctx: Instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + wantErr: true, + errorStatus: http.StatusNotFound, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := tt.ctx + if ctx == nil { + ctx = CTX + } + + createdUser, err := Instance.Client.SCIM.Users.Create(ctx, Instance.DefaultOrg.Id, tt.body) + if (err != nil) != tt.wantErr { + t.Errorf("CreateUser() error = %v, wantErr %v", err, tt.wantErr) + } + + if err != nil { + assert.IsType(t, new(scim.ScimError), err) + + var scimErr *scim.ScimError + errors.As(err, &scimErr) + assert.Equal(t, tt.scimErrorType, scimErr.ScimType) + + statusCode := tt.errorStatus + if statusCode == 0 { + statusCode = http.StatusBadRequest + } + assert.Equal(t, strconv.Itoa(statusCode), scimErr.Status) + + if tt.zitadelErrID != "" { + assert.Equal(t, tt.zitadelErrID, scimErr.ZitadelDetail.ID) + } + + return + } + + assert.NotEmpty(t, createdUser.ID) + assert.EqualValues(t, []schemas.ScimSchemaType{"urn:ietf:params:scim:schemas:core:2.0:User"}, createdUser.Resource.Schemas) + assert.Equal(t, schemas.ScimResourceTypeSingular("User"), createdUser.Resource.Meta.ResourceType) + assert.Equal(t, "http://"+Instance.Host()+path.Join(schemas.HandlerPrefix, Instance.DefaultOrg.Id, "Users", createdUser.ID), createdUser.Resource.Meta.Location) + assert.Nil(t, createdUser.Password) + + _, err = Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: createdUser.ID}) + assert.NoError(t, err) + }) + } +} + +func TestCreateUser_duplicate(t *testing.T) { + createdUser, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, minimalUserJson) + require.NoError(t, err) + + _, err = Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, minimalUserJson) + require.Error(t, err) + assert.IsType(t, new(scim.ScimError), err) + + var scimErr *scim.ScimError + errors.As(err, &scimErr) + assert.Equal(t, strconv.Itoa(http.StatusConflict), scimErr.Status) + assert.Equal(t, "User already exists", scimErr.Detail) + + _, err = Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: createdUser.ID}) + require.NoError(t, err) +} + +func TestCreateUser_metadata(t *testing.T) { + createdUser, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, fullUserJson) + require.NoError(t, err) + + md, err := Instance.Client.Mgmt.ListUserMetadata(CTX, &management.ListUserMetadataRequest{ + Id: createdUser.ID, + }) + require.NoError(t, err) + + mdMap := make(map[string]string) + for i := range md.Result { + mdMap[md.Result[i].Key] = string(md.Result[i].Value) + } + + integration.AssertMapContains(t, mdMap, "urn:zitadel:scim:name.honorificPrefix", "Ms.") + integration.AssertMapContains(t, mdMap, "urn:zitadel:scim:timezone", "America/Los_Angeles") + integration.AssertMapContains(t, mdMap, "urn:zitadel:scim:photos", `[{"value":"https://photos.example.com/profilephoto/72930000000Ccne/F","type":"photo"},{"value":"https://photos.example.com/profilephoto/72930000000Ccne/T","type":"thumbnail"}]`) + integration.AssertMapContains(t, mdMap, "urn:zitadel:scim:addresses", `[{"type":"work","streetAddress":"100 Universal City Plaza","locality":"Hollywood","region":"CA","postalCode":"91608","country":"USA","formatted":"100 Universal City Plaza\nHollywood, CA 91608 USA","primary":true},{"type":"home","streetAddress":"456 Hollywood Blvd","locality":"Hollywood","region":"CA","postalCode":"91608","country":"USA","formatted":"456 Hollywood Blvd\nHollywood, CA 91608 USA"}]`) + integration.AssertMapContains(t, mdMap, "urn:zitadel:scim:entitlements", `[{"value":"my-entitlement-1","display":"Entitlement 1","type":"main-entitlement","primary":true},{"value":"my-entitlement-2","display":"Entitlement 2","type":"secondary-entitlement"}]`) + integration.AssertMapContains(t, mdMap, "urn:zitadel:scim:externalId", "701984") + integration.AssertMapContains(t, mdMap, "urn:zitadel:scim:name.middleName", "Jane") + integration.AssertMapContains(t, mdMap, "urn:zitadel:scim:name.honorificSuffix", "III") + integration.AssertMapContains(t, mdMap, "urn:zitadel:scim:profileURL", "http://login.example.com/bjensen") + integration.AssertMapContains(t, mdMap, "urn:zitadel:scim:title", "Tour Guide") + integration.AssertMapContains(t, mdMap, "urn:zitadel:scim:locale", "en-US") + integration.AssertMapContains(t, mdMap, "urn:zitadel:scim:ims", `[{"value":"someaimhandle","type":"aim"},{"value":"twitterhandle","type":"X"}]`) + integration.AssertMapContains(t, mdMap, "urn:zitadel:scim:roles", `[{"value":"my-role-1","display":"Rolle 1","type":"main-role","primary":true},{"value":"my-role-2","display":"Rolle 2","type":"secondary-role"}]`) + + _, err = Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: createdUser.ID}) + require.NoError(t, err) +} + +func TestCreateUser_scopedExternalID(t *testing.T) { + _, err := Instance.Client.Mgmt.SetUserMetadata(CTX, &management.SetUserMetadataRequest{ + Id: Instance.Users.Get(integration.UserTypeOrgOwner).ID, + Key: "urn:zitadel:scim:provisioning_domain", + Value: []byte("fooBar"), + }) + require.NoError(t, err) + + createdUser, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, fullUserJson) + require.NoError(t, err) + + // unscoped externalID should not exist + _, err = Instance.Client.Mgmt.GetUserMetadata(CTX, &management.GetUserMetadataRequest{ + Id: createdUser.ID, + Key: "urn:zitadel:scim:externalId", + }) + integration.AssertGrpcStatus(t, codes.NotFound, err) + + // scoped externalID should exist + md, err := Instance.Client.Mgmt.GetUserMetadata(CTX, &management.GetUserMetadataRequest{ + Id: createdUser.ID, + Key: "urn:zitadel:scim:fooBar:externalId", + }) + require.NoError(t, err) + assert.Equal(t, "701984", string(md.Metadata.Value)) + + _, err = Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: createdUser.ID}) + require.NoError(t, err) +} diff --git a/internal/api/scim/metadata/context.go b/internal/api/scim/metadata/context.go new file mode 100644 index 0000000000..5be54d7123 --- /dev/null +++ b/internal/api/scim/metadata/context.go @@ -0,0 +1,23 @@ +package metadata + +import ( + "context" +) + +type provisioningDomainKeyType struct{} + +var provisioningDomainKey provisioningDomainKeyType + +type ScimContextData struct { + ProvisioningDomain string + ExternalIDScopedMetadataKey ScopedKey +} + +func SetScimContextData(ctx context.Context, data ScimContextData) context.Context { + return context.WithValue(ctx, provisioningDomainKey, data) +} + +func GetScimContextData(ctx context.Context) ScimContextData { + data, _ := ctx.Value(provisioningDomainKey).(ScimContextData) + return data +} diff --git a/internal/api/scim/metadata/metadata.go b/internal/api/scim/metadata/metadata.go new file mode 100644 index 0000000000..626d938234 --- /dev/null +++ b/internal/api/scim/metadata/metadata.go @@ -0,0 +1,60 @@ +package metadata + +import ( + "context" + "strings" +) + +type Key string +type ScopedKey string + +const ( + externalIdProvisioningDomainPlaceholder = "{provisioningDomain}" + + KeyPrefix = "urn:zitadel:scim:" + KeyProvisioningDomain Key = KeyPrefix + "provisioning_domain" + + KeyExternalId Key = KeyPrefix + "externalId" + keyScopedExternalIdTemplate = KeyPrefix + externalIdProvisioningDomainPlaceholder + ":externalId" + KeyMiddleName Key = KeyPrefix + "name.middleName" + KeyHonorificPrefix Key = KeyPrefix + "name.honorificPrefix" + KeyHonorificSuffix Key = KeyPrefix + "name.honorificSuffix" + KeyProfileUrl Key = KeyPrefix + "profileURL" + KeyTitle Key = KeyPrefix + "title" + KeyLocale Key = KeyPrefix + "locale" + KeyTimezone Key = KeyPrefix + "timezone" + KeyIms Key = KeyPrefix + "ims" + KeyPhotos Key = KeyPrefix + "photos" + KeyAddresses Key = KeyPrefix + "addresses" + KeyEntitlements Key = KeyPrefix + "entitlements" + KeyRoles Key = KeyPrefix + "roles" +) + +var ScimUserRelevantMetadataKeys = []Key{ + KeyExternalId, + KeyMiddleName, + KeyHonorificPrefix, + KeyHonorificSuffix, + KeyProfileUrl, + KeyTitle, + KeyLocale, + KeyTimezone, + KeyIms, + KeyPhotos, + KeyAddresses, + KeyEntitlements, + KeyRoles, +} + +func ScopeExternalIdKey(provisioningDomain string) ScopedKey { + return ScopedKey(strings.Replace(keyScopedExternalIdTemplate, externalIdProvisioningDomainPlaceholder, provisioningDomain, 1)) +} + +func ScopeKey(ctx context.Context, key Key) ScopedKey { + // only the externalID is scoped + if key == KeyExternalId { + return GetScimContextData(ctx).ExternalIDScopedMetadataKey + } + + return ScopedKey(key) +} diff --git a/internal/api/scim/middleware/content_type_middleware.go b/internal/api/scim/middleware/content_type_middleware.go new file mode 100644 index 0000000000..9b456bb141 --- /dev/null +++ b/internal/api/scim/middleware/content_type_middleware.go @@ -0,0 +1,53 @@ +package middleware + +import ( + "mime" + "net/http" + "strings" + + "github.com/zitadel/logging" + + zhttp "github.com/zitadel/zitadel/internal/api/http" + "github.com/zitadel/zitadel/internal/api/http/middleware" + "github.com/zitadel/zitadel/internal/zerrors" +) + +const ( + ContentTypeScim = "application/scim+json" + ContentTypeJson = "application/json" +) + +func ContentTypeMiddleware(next middleware.HandlerFuncWithError) middleware.HandlerFuncWithError { + return func(w http.ResponseWriter, r *http.Request) error { + w.Header().Set(zhttp.ContentType, ContentTypeScim) + + if !validateContentType(r.Header.Get(zhttp.ContentType)) { + return zerrors.ThrowInvalidArgumentf(nil, "SMCM-12x4", "Invalid content type header") + } + + if !validateContentType(r.Header.Get(zhttp.Accept)) { + return zerrors.ThrowInvalidArgumentf(nil, "SMCM-12x5", "Invalid accept header") + } + + return next(w, r) + } +} + +func validateContentType(contentType string) bool { + if contentType == "" { + return true + } + + mediaType, params, err := mime.ParseMediaType(contentType) + if err != nil { + logging.OnError(err).Warn("failed to parse content type header") + return false + } + + if mediaType != "" && !strings.EqualFold(mediaType, ContentTypeJson) && !strings.EqualFold(mediaType, ContentTypeScim) { + return false + } + + charset, ok := params["charset"] + return !ok || strings.EqualFold(charset, "utf-8") +} diff --git a/internal/api/scim/middleware/content_type_middleware_test.go b/internal/api/scim/middleware/content_type_middleware_test.go new file mode 100644 index 0000000000..918d4618ae --- /dev/null +++ b/internal/api/scim/middleware/content_type_middleware_test.go @@ -0,0 +1,107 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + zhttp "github.com/zitadel/zitadel/internal/api/http" +) + +func TestContentTypeMiddleware(t *testing.T) { + tests := []struct { + name string + contentTypeHeader string + acceptHeader string + wantErr bool + }{ + { + name: "valid", + contentTypeHeader: "application/scim+json", + acceptHeader: "application/scim+json", + wantErr: false, + }, + { + name: "invalid content type", + contentTypeHeader: "application/octet-stream", + acceptHeader: "application/json", + wantErr: true, + }, + { + name: "invalid accept", + contentTypeHeader: "application/json", + acceptHeader: "application/octet-stream", + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest("GET", "/", nil) + if tt.acceptHeader != "" { + req.Header.Set(zhttp.Accept, tt.acceptHeader) + } + + if tt.contentTypeHeader != "" { + req.Header.Set(zhttp.ContentType, tt.contentTypeHeader) + } + + err := ContentTypeMiddleware(func(w http.ResponseWriter, r *http.Request) error { + return nil + })(httptest.NewRecorder(), req) + if (err != nil) != tt.wantErr { + t.Errorf("ContentTypeMiddleware() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func Test_validateContentType(t *testing.T) { + tests := []struct { + name string + contentType string + want bool + }{ + { + name: "empty", + contentType: "", + want: true, + }, + { + name: "json", + contentType: "application/json", + want: true, + }, + { + name: "scim", + contentType: "application/scim+json", + want: true, + }, + { + name: "json utf-8", + contentType: "application/json; charset=utf-8", + want: true, + }, + { + name: "scim utf-8", + contentType: "application/scim+json; charset=utf-8", + want: true, + }, + { + name: "unknown content type", + contentType: "application/octet-stream", + want: false, + }, + { + name: "unknown charset", + contentType: "application/scim+json; charset=utf-16", + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := validateContentType(tt.contentType); got != tt.want { + t.Errorf("validateContentType() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/api/scim/middleware/scim_context_middleware.go b/internal/api/scim/middleware/scim_context_middleware.go new file mode 100644 index 0000000000..c52f6f13f6 --- /dev/null +++ b/internal/api/scim/middleware/scim_context_middleware.go @@ -0,0 +1,54 @@ +package middleware + +import ( + "context" + "net/http" + + "github.com/zitadel/zitadel/internal/api/authz" + zhttp "github.com/zitadel/zitadel/internal/api/http/middleware" + smetadata "github.com/zitadel/zitadel/internal/api/scim/metadata" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func ScimContextMiddleware(q *query.Queries) func(next zhttp.HandlerFuncWithError) zhttp.HandlerFuncWithError { + return func(next zhttp.HandlerFuncWithError) zhttp.HandlerFuncWithError { + return func(w http.ResponseWriter, r *http.Request) error { + ctx, err := initScimContext(r.Context(), q) + if err != nil { + return err + } + + return next(w, r.WithContext(ctx)) + } + } +} + +func initScimContext(ctx context.Context, q *query.Queries) (context.Context, error) { + data := smetadata.ScimContextData{ + ProvisioningDomain: "", + ExternalIDScopedMetadataKey: smetadata.ScopedKey(smetadata.KeyExternalId), + } + + ctx = smetadata.SetScimContextData(ctx, data) + + userID := authz.GetCtxData(ctx).UserID + metadata, err := q.GetUserMetadataByKey(ctx, false, userID, string(smetadata.KeyProvisioningDomain), false) + if err != nil { + if zerrors.IsNotFound(err) { + return ctx, nil + } + + return ctx, err + } + + if metadata == nil { + return ctx, nil + } + + data.ProvisioningDomain = string(metadata.Value) + if data.ProvisioningDomain != "" { + data.ExternalIDScopedMetadataKey = smetadata.ScopeExternalIdKey(data.ProvisioningDomain) + } + return smetadata.SetScimContextData(ctx, data), nil +} diff --git a/internal/api/scim/resources/resource_handler.go b/internal/api/scim/resources/resource_handler.go new file mode 100644 index 0000000000..c624253ee9 --- /dev/null +++ b/internal/api/scim/resources/resource_handler.go @@ -0,0 +1,61 @@ +package resources + +import ( + "context" + "path" + "strconv" + "time" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/api/http" + "github.com/zitadel/zitadel/internal/api/scim/schemas" + "github.com/zitadel/zitadel/internal/domain" +) + +type ResourceHandler[T ResourceHolder] interface { + ResourceNameSingular() schemas.ScimResourceTypeSingular + ResourceNamePlural() schemas.ScimResourceTypePlural + SchemaType() schemas.ScimSchemaType + NewResource() T + + Create(ctx context.Context, resource T) (T, error) +} + +type Resource struct { + Schemas []schemas.ScimSchemaType `json:"schemas"` + Meta *ResourceMeta `json:"meta"` +} + +type ResourceMeta struct { + ResourceType schemas.ScimResourceTypeSingular `json:"resourceType"` + Created time.Time `json:"created"` + LastModified time.Time `json:"lastModified"` + Version string `json:"version"` + Location string `json:"location"` +} + +type ResourceHolder interface { + GetResource() *Resource +} + +func buildResource[T ResourceHolder](ctx context.Context, handler ResourceHandler[T], details *domain.ObjectDetails) *Resource { + created := details.CreationDate.UTC() + if created.IsZero() { + created = details.EventDate.UTC() + } + + return &Resource{ + Schemas: []schemas.ScimSchemaType{handler.SchemaType()}, + Meta: &ResourceMeta{ + ResourceType: handler.ResourceNameSingular(), + Created: created, + LastModified: details.EventDate.UTC(), + Version: strconv.FormatUint(details.Sequence, 10), + Location: buildLocation(ctx, handler, details.ID), + }, + } +} + +func buildLocation[T ResourceHolder](ctx context.Context, handler ResourceHandler[T], id string) string { + return http.DomainContext(ctx).Origin() + path.Join(schemas.HandlerPrefix, authz.GetCtxData(ctx).OrgID, string(handler.ResourceNamePlural()), id) +} diff --git a/internal/api/scim/resources/resource_handler_adapter.go b/internal/api/scim/resources/resource_handler_adapter.go new file mode 100644 index 0000000000..79f74f7bfb --- /dev/null +++ b/internal/api/scim/resources/resource_handler_adapter.go @@ -0,0 +1,69 @@ +package resources + +import ( + "encoding/json" + "net/http" + "slices" + + "github.com/zitadel/zitadel/internal/api/scim/schemas" + "github.com/zitadel/zitadel/internal/api/scim/serrors" + "github.com/zitadel/zitadel/internal/zerrors" +) + +type ResourceHandlerAdapter[T ResourceHolder] struct { + handler ResourceHandler[T] +} + +type ListRequest struct { + // Count An integer indicating the desired maximum number of query results per page. OPTIONAL. + Count uint64 `json:"count" schema:"count"` + + // StartIndex An integer indicating the 1-based index of the first query result. Optional. + StartIndex uint64 `json:"startIndex" schema:"startIndex"` +} + +type ListResponse[T any] struct { + Schemas []schemas.ScimSchemaType `json:"schemas"` + ItemsPerPage uint64 `json:"itemsPerPage"` + TotalResults uint64 `json:"totalResults"` + StartIndex uint64 `json:"startIndex"` + Resources []T `json:"Resources"` // according to the rfc this is the only field in PascalCase... +} + +func NewResourceHandlerAdapter[T ResourceHolder](handler ResourceHandler[T]) *ResourceHandlerAdapter[T] { + return &ResourceHandlerAdapter[T]{ + handler, + } +} + +func (adapter *ResourceHandlerAdapter[T]) Create(r *http.Request) (T, error) { + entity, err := adapter.readEntityFromBody(r) + if err != nil { + return entity, err + } + + return adapter.handler.Create(r.Context(), entity) +} + +func (adapter *ResourceHandlerAdapter[T]) readEntityFromBody(r *http.Request) (T, error) { + entity := adapter.handler.NewResource() + err := json.NewDecoder(r.Body).Decode(entity) + if err != nil { + if zerrors.IsZitadelError(err) { + return entity, err + } + + return entity, serrors.ThrowInvalidSyntax(zerrors.ThrowInvalidArgumentf(nil, "SCIM-ucrjson", "Could not deserialize json: %v", err.Error())) + } + + resource := entity.GetResource() + if resource == nil { + return entity, serrors.ThrowInvalidSyntax(zerrors.ThrowInvalidArgument(nil, "SCIM-xxrjson", "Could not get resource, is the schema correct?")) + } + + if !slices.Contains(resource.Schemas, adapter.handler.SchemaType()) { + return entity, serrors.ThrowInvalidSyntax(zerrors.ThrowInvalidArgumentf(nil, "SCIM-xxrschema", "Expected schema %v is not provided", adapter.handler.SchemaType())) + } + + return entity, nil +} diff --git a/internal/api/scim/resources/user.go b/internal/api/scim/resources/user.go new file mode 100644 index 0000000000..fef9a34c6c --- /dev/null +++ b/internal/api/scim/resources/user.go @@ -0,0 +1,146 @@ +package resources + +import ( + "context" + + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/api/authz" + scim_config "github.com/zitadel/zitadel/internal/api/scim/config" + schemas2 "github.com/zitadel/zitadel/internal/api/scim/schemas" + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/query" +) + +type UsersHandler struct { + command *command.Commands + query *query.Queries + userCodeAlg crypto.EncryptionAlgorithm + config *scim_config.Config +} + +type ScimUser struct { + *Resource + ID string `json:"id"` + ExternalID string `json:"externalId,omitempty"` + UserName string `json:"userName,omitempty"` + Name *ScimUserName `json:"name,omitempty"` + DisplayName string `json:"displayName,omitempty"` + NickName string `json:"nickName,omitempty"` + ProfileUrl *schemas2.HttpURL `json:"profileUrl,omitempty"` + Title string `json:"title,omitempty"` + PreferredLanguage language.Tag `json:"preferredLanguage,omitempty"` + Locale string `json:"locale,omitempty"` + Timezone string `json:"timezone,omitempty"` + Active bool `json:"active,omitempty"` + Emails []*ScimEmail `json:"emails,omitempty"` + PhoneNumbers []*ScimPhoneNumber `json:"phoneNumbers,omitempty"` + Password *schemas2.WriteOnlyString `json:"password,omitempty"` + Ims []*ScimIms `json:"ims,omitempty"` + Addresses []*ScimAddress `json:"addresses,omitempty"` + Photos []*ScimPhoto `json:"photos,omitempty"` + Entitlements []*ScimEntitlement `json:"entitlements,omitempty"` + Roles []*ScimRole `json:"roles,omitempty"` +} + +type ScimEntitlement struct { + Value string `json:"value,omitempty"` + Display string `json:"display,omitempty"` + Type string `json:"type,omitempty"` + Primary bool `json:"primary,omitempty"` +} + +type ScimRole struct { + Value string `json:"value,omitempty"` + Display string `json:"display,omitempty"` + Type string `json:"type,omitempty"` + Primary bool `json:"primary,omitempty"` +} + +type ScimPhoto struct { + Value schemas2.HttpURL `json:"value"` + Display string `json:"display,omitempty"` + Type string `json:"type"` + Primary bool `json:"primary,omitempty"` +} + +type ScimAddress struct { + Type string `json:"type,omitempty"` + StreetAddress string `json:"streetAddress,omitempty"` + Locality string `json:"locality,omitempty"` + Region string `json:"region,omitempty"` + PostalCode string `json:"postalCode,omitempty"` + Country string `json:"country,omitempty"` + Formatted string `json:"formatted,omitempty"` + Primary bool `json:"primary,omitempty"` +} + +type ScimIms struct { + Value string `json:"value"` + Type string `json:"type"` +} + +type ScimEmail struct { + Value string `json:"value"` + Primary bool `json:"primary"` +} + +type ScimPhoneNumber struct { + Value string `json:"value"` + Primary bool `json:"primary"` +} + +type ScimUserName struct { + Formatted string `json:"formatted,omitempty"` + FamilyName string `json:"familyName,omitempty"` + GivenName string `json:"givenName,omitempty"` + MiddleName string `json:"middleName,omitempty"` + HonorificPrefix string `json:"honorificPrefix,omitempty"` + HonorificSuffix string `json:"honorificSuffix,omitempty"` +} + +func NewUsersHandler( + command *command.Commands, + query *query.Queries, + userCodeAlg crypto.EncryptionAlgorithm, + config *scim_config.Config) ResourceHandler[*ScimUser] { + return &UsersHandler{command, query, userCodeAlg, config} +} + +func (h *UsersHandler) ResourceNameSingular() schemas2.ScimResourceTypeSingular { + return schemas2.UserResourceType +} + +func (h *UsersHandler) ResourceNamePlural() schemas2.ScimResourceTypePlural { + return schemas2.UsersResourceType +} + +func (u *ScimUser) GetResource() *Resource { + return u.Resource +} + +func (h *UsersHandler) NewResource() *ScimUser { + return new(ScimUser) +} + +func (h *UsersHandler) SchemaType() schemas2.ScimSchemaType { + return schemas2.IdUser +} + +func (h *UsersHandler) Create(ctx context.Context, user *ScimUser) (*ScimUser, error) { + orgID := authz.GetCtxData(ctx).OrgID + addHuman, err := h.mapToAddHuman(ctx, user) + if err != nil { + return nil, err + } + + err = h.command.AddUserHuman(ctx, orgID, addHuman, true, h.userCodeAlg) + if err != nil { + return nil, err + } + + user.ID = addHuman.Details.ID + user.Resource = buildResource(ctx, h, addHuman.Details) + return user, err +} diff --git a/internal/api/scim/resources/user_mapping.go b/internal/api/scim/resources/user_mapping.go new file mode 100644 index 0000000000..8ee7cd511c --- /dev/null +++ b/internal/api/scim/resources/user_mapping.go @@ -0,0 +1,81 @@ +package resources + +import ( + "context" + + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/domain" +) + +func (h *UsersHandler) mapToAddHuman(ctx context.Context, scimUser *ScimUser) (*command.AddHuman, error) { + // zitadel has its own state mechanism + // ignore scimUser.Active + human := &command.AddHuman{ + Username: scimUser.UserName, + NickName: scimUser.NickName, + DisplayName: scimUser.DisplayName, + Email: h.mapPrimaryEmail(scimUser), + Phone: h.mapPrimaryPhone(scimUser), + } + + md, err := h.mapMetadataToCommands(ctx, scimUser) + if err != nil { + return nil, err + } + human.Metadata = md + + if scimUser.Password != nil { + human.Password = scimUser.Password.String() + scimUser.Password = nil + } + + if scimUser.Name != nil { + human.FirstName = scimUser.Name.GivenName + human.LastName = scimUser.Name.FamilyName + + // the direct mapping displayName => displayName has priority + // over the formatted name assignment + if human.DisplayName == "" { + human.DisplayName = scimUser.Name.Formatted + } + } + + if err := domain.LanguageIsDefined(scimUser.PreferredLanguage); err != nil { + human.PreferredLanguage = language.English + scimUser.PreferredLanguage = language.English + } + + return human, nil +} + +func (h *UsersHandler) mapPrimaryEmail(scimUser *ScimUser) command.Email { + for _, email := range scimUser.Emails { + if !email.Primary { + continue + } + + return command.Email{ + Address: domain.EmailAddress(email.Value), + Verified: h.config.EmailVerified, + } + } + + return command.Email{} +} + +func (h *UsersHandler) mapPrimaryPhone(scimUser *ScimUser) command.Phone { + for _, phone := range scimUser.PhoneNumbers { + if !phone.Primary { + continue + } + + return command.Phone{ + Number: domain.PhoneNumber(phone.Value), + Verified: h.config.PhoneVerified, + } + } + + return command.Phone{} +} diff --git a/internal/api/scim/resources/user_metadata.go b/internal/api/scim/resources/user_metadata.go new file mode 100644 index 0000000000..3d745d6857 --- /dev/null +++ b/internal/api/scim/resources/user_metadata.go @@ -0,0 +1,150 @@ +package resources + +import ( + "context" + "encoding/json" + "time" + + "github.com/zitadel/logging" + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/api/scim/metadata" + "github.com/zitadel/zitadel/internal/api/scim/schemas" + "github.com/zitadel/zitadel/internal/api/scim/serrors" + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func (h *UsersHandler) mapMetadataToCommands(ctx context.Context, user *ScimUser) ([]*command.AddMetadataEntry, error) { + md := make([]*command.AddMetadataEntry, 0, len(metadata.ScimUserRelevantMetadataKeys)) + for _, key := range metadata.ScimUserRelevantMetadataKeys { + value, err := getValueForMetadataKey(user, key) + if err != nil { + return nil, err + } + + if len(value) > 0 { + md = append(md, &command.AddMetadataEntry{ + Key: string(metadata.ScopeKey(ctx, key)), + Value: value, + }) + } + } + + return md, nil +} + +func getValueForMetadataKey(user *ScimUser, key metadata.Key) ([]byte, error) { + value := getRawValueForMetadataKey(user, key) + if value == nil { + return nil, nil + } + + switch key { + // json values + case metadata.KeyEntitlements: + fallthrough + case metadata.KeyIms: + fallthrough + case metadata.KeyPhotos: + fallthrough + case metadata.KeyAddresses: + fallthrough + case metadata.KeyRoles: + return json.Marshal(value) + + // http url values + case metadata.KeyProfileUrl: + return []byte(value.(*schemas.HttpURL).String()), nil + + // raw values + case metadata.KeyProvisioningDomain: + fallthrough + case metadata.KeyExternalId: + fallthrough + case metadata.KeyMiddleName: + fallthrough + case metadata.KeyHonorificSuffix: + fallthrough + case metadata.KeyHonorificPrefix: + fallthrough + case metadata.KeyTitle: + fallthrough + case metadata.KeyLocale: + fallthrough + case metadata.KeyTimezone: + valueStr := value.(string) + if valueStr == "" { + return nil, nil + } + + return []byte(valueStr), validateValueForMetadataKey(valueStr, key) + } + + logging.Panicf("Unknown metadata key %s", key) + return nil, nil +} + +func validateValueForMetadataKey(v string, key metadata.Key) error { + //nolint:exhaustive + switch key { + case metadata.KeyLocale: + if _, err := language.Parse(v); err != nil { + return serrors.ThrowInvalidValue(zerrors.ThrowInvalidArgument(err, "SCIM-MD11", "Could not parse locale")) + } + return nil + case metadata.KeyTimezone: + if _, err := time.LoadLocation(v); err != nil { + return serrors.ThrowInvalidValue(zerrors.ThrowInvalidArgument(err, "SCIM-MD12", "Could not parse timezone")) + } + + return nil + } + + return nil +} + +func getRawValueForMetadataKey(user *ScimUser, key metadata.Key) interface{} { + switch key { + case metadata.KeyIms: + return user.Ims + case metadata.KeyPhotos: + return user.Photos + case metadata.KeyAddresses: + return user.Addresses + case metadata.KeyEntitlements: + return user.Entitlements + case metadata.KeyRoles: + return user.Roles + case metadata.KeyMiddleName: + if user.Name == nil { + return "" + } + return user.Name.MiddleName + case metadata.KeyHonorificPrefix: + if user.Name == nil { + return "" + } + return user.Name.HonorificPrefix + case metadata.KeyHonorificSuffix: + if user.Name == nil { + return "" + } + return user.Name.HonorificSuffix + case metadata.KeyExternalId: + return user.ExternalID + case metadata.KeyProfileUrl: + return user.ProfileUrl + case metadata.KeyTitle: + return user.Title + case metadata.KeyLocale: + return user.Locale + case metadata.KeyTimezone: + return user.Timezone + case metadata.KeyProvisioningDomain: + break + } + + logging.Panicf("Unknown or unsupported metadata key %s", key) + return nil +} diff --git a/internal/api/scim/schemas/schemas.go b/internal/api/scim/schemas/schemas.go new file mode 100644 index 0000000000..662a31f46f --- /dev/null +++ b/internal/api/scim/schemas/schemas.go @@ -0,0 +1,20 @@ +package schemas + +type ScimSchemaType string +type ScimResourceTypeSingular string +type ScimResourceTypePlural string + +const ( + idPrefixMessages = "urn:ietf:params:scim:api:messages:2.0:" + idPrefixCore = "urn:ietf:params:scim:schemas:core:2.0:" + idPrefixZitadelMessages = "urn:ietf:params:scim:api:zitadel:messages:2.0:" + + IdUser ScimSchemaType = idPrefixCore + "User" + IdError ScimSchemaType = idPrefixMessages + "Error" + IdZitadelErrorDetail ScimSchemaType = idPrefixZitadelMessages + "ErrorDetail" + + UserResourceType ScimResourceTypeSingular = "User" + UsersResourceType ScimResourceTypePlural = "Users" + + HandlerPrefix = "/scim/v2" +) diff --git a/internal/api/scim/schemas/string.go b/internal/api/scim/schemas/string.go new file mode 100644 index 0000000000..b62e50893d --- /dev/null +++ b/internal/api/scim/schemas/string.go @@ -0,0 +1,28 @@ +package schemas + +import "encoding/json" + +// WriteOnlyString a write only string is not serializable to json. +// in the SCIM RFC it has a mutability of writeOnly. +// This increases security to really ensure this is never sent to a client. +type WriteOnlyString string + +func NewWriteOnlyString(s string) *WriteOnlyString { + wos := WriteOnlyString(s) + return &wos +} + +func (s *WriteOnlyString) MarshalJSON() ([]byte, error) { + return []byte("null"), nil +} + +func (s *WriteOnlyString) UnmarshalJSON(bytes []byte) error { + var str string + err := json.Unmarshal(bytes, &str) + *s = WriteOnlyString(str) + return err +} + +func (s *WriteOnlyString) String() string { + return string(*s) +} diff --git a/internal/api/scim/schemas/string_test.go b/internal/api/scim/schemas/string_test.go new file mode 100644 index 0000000000..c48130a5d1 --- /dev/null +++ b/internal/api/scim/schemas/string_test.go @@ -0,0 +1,70 @@ +package schemas + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestWriteOnlyString_MarshalJSON(t *testing.T) { + tests := []struct { + name string + s WriteOnlyString + }{ + { + name: "always returns null", + s: "foo bar", + }, + { + name: "empty string returns null", + s: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := json.Marshal(&tt.s) + assert.NoError(t, err) + assert.Equal(t, "null", string(got)) + }) + } +} + +func TestWriteOnlyString_UnmarshalJSON(t *testing.T) { + tests := []struct { + name string + input []byte + want WriteOnlyString + wantErr bool + }{ + { + name: "string", + input: []byte(`"fooBar"`), + want: "fooBar", + wantErr: false, + }, + { + name: "empty string", + input: []byte(`""`), + want: "", + wantErr: false, + }, + { + name: "bad format", + input: []byte(`"bad "format"`), + want: "", + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got WriteOnlyString + err := json.Unmarshal(tt.input, &got) + if (err != nil) != tt.wantErr { + t.Errorf("UnmarshalJSON() error = %v, wantErr %v", err, tt.wantErr) + } + + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/api/scim/schemas/url.go b/internal/api/scim/schemas/url.go new file mode 100644 index 0000000000..343803bc04 --- /dev/null +++ b/internal/api/scim/schemas/url.go @@ -0,0 +1,50 @@ +package schemas + +import ( + "encoding/json" + "net/url" + + "github.com/zitadel/zitadel/internal/zerrors" +) + +type HttpURL url.URL + +func ParseHTTPURL(rawURL string) (*HttpURL, error) { + parsedURL, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + + if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { + return nil, zerrors.ThrowInvalidArgumentf(nil, "SCIM-htturl1", "HTTP URL expected, got %v", parsedURL.Scheme) + } + + return (*HttpURL)(parsedURL), nil +} + +func (u *HttpURL) UnmarshalJSON(data []byte) error { + var urlStr string + if err := json.Unmarshal(data, &urlStr); err != nil { + return err + } + + parsedURL, err := ParseHTTPURL(urlStr) + if err != nil { + return err + } + + *u = *parsedURL + return nil +} + +func (u *HttpURL) MarshalJSON() ([]byte, error) { + return json.Marshal(u.String()) +} + +func (u *HttpURL) String() string { + if u == nil { + return "" + } + + return (*url.URL)(u).String() +} diff --git a/internal/api/scim/schemas/url_test.go b/internal/api/scim/schemas/url_test.go new file mode 100644 index 0000000000..a6a60322e0 --- /dev/null +++ b/internal/api/scim/schemas/url_test.go @@ -0,0 +1,182 @@ +package schemas + +import ( + "reflect" + "testing" + + "github.com/goccy/go-json" + "github.com/stretchr/testify/assert" + "github.com/zitadel/logging" +) + +func TestHttpURL_MarshalJSON(t *testing.T) { + tests := []struct { + name string + u *HttpURL + want []byte + wantErr bool + }{ + { + name: "http url", + u: mustParseURL("http://example.com"), + want: []byte(`"http://example.com"`), + wantErr: false, + }, + { + name: "https url", + u: mustParseURL("https://example.com"), + want: []byte(`"https://example.com"`), + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := json.Marshal(tt.u) + if (err != nil) != tt.wantErr { + t.Errorf("MarshalJSON() error = %v, wantErr %v", err, tt.wantErr) + return + } + + assert.Equal(t, string(got), string(tt.want)) + }) + } +} + +func TestHttpURL_UnmarshalJSON(t *testing.T) { + tests := []struct { + name string + data []byte + want *HttpURL + wantErr bool + }{ + { + name: "http url", + data: []byte(`"http://example.com"`), + want: mustParseURL("http://example.com"), + wantErr: false, + }, + { + name: "https url", + data: []byte(`"https://example.com"`), + want: mustParseURL("https://example.com"), + wantErr: false, + }, + { + name: "ftp url should fail", + data: []byte(`"ftp://example.com"`), + want: nil, + wantErr: true, + }, + { + name: "no url should fail", + data: []byte(`"test"`), + want: nil, + wantErr: true, + }, + { + name: "number should fail", + data: []byte(`120`), + want: nil, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + url := new(HttpURL) + err := json.Unmarshal(tt.data, url) + if (err != nil) != tt.wantErr { + t.Errorf("UnmarshalJSON() error = %v, wantErr %v", err, tt.wantErr) + } + + if tt.wantErr { + return + } + + assert.Equal(t, tt.want.String(), url.String()) + }) + } +} + +func TestHttpURL_String(t *testing.T) { + tests := []struct { + name string + u *HttpURL + want string + }{ + { + name: "http url", + u: mustParseURL("http://example.com"), + want: "http://example.com", + }, + { + name: "https url", + u: mustParseURL("https://example.com"), + want: "https://example.com", + }, + { + name: "nil", + u: nil, + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.u.String(); got != tt.want { + t.Errorf("String() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestParseHTTPURL(t *testing.T) { + tests := []struct { + name string + rawURL string + want *HttpURL + wantErr bool + }{ + { + name: "http url", + rawURL: "http://example.com", + want: mustParseURL("http://example.com"), + wantErr: false, + }, + { + name: "https url", + rawURL: "https://example.com", + want: mustParseURL("https://example.com"), + wantErr: false, + }, + { + name: "ftp url should fail", + rawURL: "ftp://example.com", + want: nil, + wantErr: true, + }, + { + name: "no url should fail", + rawURL: "test", + want: nil, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseHTTPURL(tt.rawURL) + if (err != nil) != tt.wantErr { + t.Errorf("ParseHTTPURL() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("ParseHTTPURL() got = %v, want %v", got, tt.want) + } + }) + } +} + +func mustParseURL(rawURL string) *HttpURL { + url, err := ParseHTTPURL(rawURL) + logging.OnError(err).Fatal("failed to parse URL") + return url +} diff --git a/internal/api/scim/serrors/errors.go b/internal/api/scim/serrors/errors.go new file mode 100644 index 0000000000..fffd598b27 --- /dev/null +++ b/internal/api/scim/serrors/errors.go @@ -0,0 +1,140 @@ +package serrors + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" + + "github.com/zitadel/logging" + "golang.org/x/text/language" + + http_util "github.com/zitadel/zitadel/internal/api/http" + zhttp_middleware "github.com/zitadel/zitadel/internal/api/http/middleware" + "github.com/zitadel/zitadel/internal/api/scim/schemas" + "github.com/zitadel/zitadel/internal/i18n" + "github.com/zitadel/zitadel/internal/zerrors" +) + +type scimErrorType string + +type wrappedScimError struct { + Parent error + ScimType scimErrorType +} + +type scimError struct { + Schemas []schemas.ScimSchemaType `json:"schemas"` + ScimType scimErrorType `json:"scimType,omitempty"` + Detail string `json:"detail,omitempty"` + StatusCode int `json:"-"` + Status string `json:"status"` + ZitadelDetail *errorDetail `json:"urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail,omitempty"` +} + +type errorDetail struct { + ID string `json:"id"` + Message string `json:"message"` +} + +const ( + // ScimTypeInvalidValue A required value was missing, + // or the value specified was not compatible with the operation, + // or attribute type (see Section 2.2 of RFC7643), + // or resource schema (see Section 4 of RFC7643). + ScimTypeInvalidValue scimErrorType = "invalidValue" + + // ScimTypeInvalidSyntax The request body message structure was invalid or did + // not conform to the request schema. + ScimTypeInvalidSyntax scimErrorType = "invalidSyntax" +) + +var translator *i18n.Translator + +func ErrorHandler(next zhttp_middleware.HandlerFuncWithError) http.Handler { + var err error + translator, err = i18n.NewZitadelTranslator(language.English) + logging.OnError(err).Panic("unable to get translator") + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err = next(w, r); err == nil { + return + } + + scimErr := mapToScimJsonError(r.Context(), err) + w.WriteHeader(scimErr.StatusCode) + + jsonErr := json.NewEncoder(w).Encode(scimErr) + logging.OnError(jsonErr).Warn("Failed to marshal scim error response") + }) +} + +func ThrowInvalidValue(parent error) error { + return &wrappedScimError{ + Parent: parent, + ScimType: ScimTypeInvalidValue, + } +} + +func ThrowInvalidSyntax(parent error) error { + return &wrappedScimError{ + Parent: parent, + ScimType: ScimTypeInvalidSyntax, + } +} + +func (err *scimError) Error() string { + return fmt.Sprintf("SCIM Error: %s: %s", err.ScimType, err.Detail) +} + +func (err *wrappedScimError) Error() string { + return fmt.Sprintf("SCIM Error: %s: %s", err.ScimType, err.Parent.Error()) +} + +func mapToScimJsonError(ctx context.Context, err error) *scimError { + scimErr := new(wrappedScimError) + if ok := errors.As(err, &scimErr); ok { + mappedErr := mapToScimJsonError(ctx, scimErr.Parent) + mappedErr.ScimType = scimErr.ScimType + return mappedErr + } + + zitadelErr := new(zerrors.ZitadelError) + if ok := errors.As(err, &zitadelErr); !ok { + return &scimError{ + Schemas: []schemas.ScimSchemaType{schemas.IdError}, + Detail: "Unknown internal server error", + Status: strconv.Itoa(http.StatusInternalServerError), + StatusCode: http.StatusInternalServerError, + } + } + + statusCode, ok := http_util.ZitadelErrorToHTTPStatusCode(err) + if !ok { + statusCode = http.StatusInternalServerError + } + + localizedMsg := translator.LocalizeFromCtx(ctx, zitadelErr.GetMessage(), nil) + return &scimError{ + Schemas: []schemas.ScimSchemaType{schemas.IdError, schemas.IdZitadelErrorDetail}, + ScimType: mapErrorToScimErrorType(err), + Detail: localizedMsg, + StatusCode: statusCode, + Status: strconv.Itoa(statusCode), + ZitadelDetail: &errorDetail{ + ID: zitadelErr.GetID(), + Message: zitadelErr.GetMessage(), + }, + } +} + +func mapErrorToScimErrorType(err error) scimErrorType { + switch { + case zerrors.IsErrorInvalidArgument(err): + return ScimTypeInvalidValue + default: + return "" + } +} diff --git a/internal/api/scim/serrors/errors_test.go b/internal/api/scim/serrors/errors_test.go new file mode 100644 index 0000000000..71d8018355 --- /dev/null +++ b/internal/api/scim/serrors/errors_test.go @@ -0,0 +1,110 @@ +package serrors + +import ( + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/zitadel/zitadel/internal/i18n" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func TestErrorHandler(t *testing.T) { + i18n.MustLoadSupportedLanguagesFromDir() + + tests := []struct { + name string + err error + wantStatus int + wantBody string + }{ + { + name: "scim error", + err: ThrowInvalidSyntax(zerrors.ThrowInvalidArgument(nil, "FOO", "Invalid syntax")), + wantStatus: http.StatusBadRequest, + wantBody: `{ + "schemas":[ + "urn:ietf:params:scim:api:messages:2.0:Error", + "urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail" + ], + "scimType":"invalidSyntax", + "detail":"Invalid syntax", + "status":"400", + "urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail": { + "id":"FOO", + "message":"Invalid syntax" + } + }`, + }, + { + name: "zitadel error", + err: zerrors.ThrowInvalidArgument(nil, "FOO", "Invalid syntax"), + wantStatus: http.StatusBadRequest, + wantBody: `{ + "schemas":[ + "urn:ietf:params:scim:api:messages:2.0:Error", + "urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail" + ], + "scimType":"invalidValue", + "detail":"Invalid syntax", + "status":"400", + "urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail": { + "id":"FOO", + "message":"Invalid syntax" + } + }`, + }, + { + name: "zitadel internal error", + err: zerrors.ThrowInternal(nil, "FOO", "Internal error"), + wantStatus: http.StatusInternalServerError, + wantBody: `{ + "schemas":[ + "urn:ietf:params:scim:api:messages:2.0:Error", + "urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail" + ], + "detail":"Internal error", + "status":"500", + "urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail": { + "id":"FOO", + "message":"Internal error" + } + }`, + }, + { + name: "unknown error", + err: errors.New("FOO"), + wantStatus: http.StatusInternalServerError, + wantBody: `{ + "schemas":[ + "urn:ietf:params:scim:api:messages:2.0:Error" + ], + "detail":"Unknown internal server error", + "status":"500" + }`, + }, + { + name: "no error", + err: nil, + wantStatus: http.StatusOK, + wantBody: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest("GET", "/", nil) + recorder := httptest.NewRecorder() + ErrorHandler(func(http.ResponseWriter, *http.Request) error { + return tt.err + }).ServeHTTP(recorder, req) + assert.Equal(t, tt.wantStatus, recorder.Code) + + if tt.wantBody != "" { + assert.JSONEq(t, tt.wantBody, recorder.Body.String()) + } + }) + } +} diff --git a/internal/api/scim/server.go b/internal/api/scim/server.go new file mode 100644 index 0000000000..c23aadf247 --- /dev/null +++ b/internal/api/scim/server.go @@ -0,0 +1,74 @@ +package scim + +import ( + "encoding/json" + "net/http" + "path" + + "github.com/gorilla/mux" + "github.com/zitadel/logging" + + "github.com/zitadel/zitadel/internal/api/authz" + zhttp "github.com/zitadel/zitadel/internal/api/http" + zhttp_middlware "github.com/zitadel/zitadel/internal/api/http/middleware" + sconfig "github.com/zitadel/zitadel/internal/api/scim/config" + smiddleware "github.com/zitadel/zitadel/internal/api/scim/middleware" + sresources "github.com/zitadel/zitadel/internal/api/scim/resources" + "github.com/zitadel/zitadel/internal/api/scim/schemas" + "github.com/zitadel/zitadel/internal/api/scim/serrors" + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/query" +) + +func NewServer( + command *command.Commands, + query *query.Queries, + verifier *authz.ApiTokenVerifier, + userCodeAlg crypto.EncryptionAlgorithm, + config *sconfig.Config, + middlewares ...zhttp_middlware.MiddlewareWithErrorFunc) http.Handler { + verifier.RegisterServer("SCIM-V2", schemas.HandlerPrefix, AuthMapping) + return buildHandler(command, query, userCodeAlg, config, middlewares...) +} + +func buildHandler( + command *command.Commands, + query *query.Queries, + userCodeAlg crypto.EncryptionAlgorithm, + cfg *sconfig.Config, + middlewares ...zhttp_middlware.MiddlewareWithErrorFunc) http.Handler { + + router := mux.NewRouter() + + // content type middleware needs to run at the very beginning to correctly set content types of errors + middlewares = append([]zhttp_middlware.MiddlewareWithErrorFunc{smiddleware.ContentTypeMiddleware}, middlewares...) + middlewares = append(middlewares, smiddleware.ScimContextMiddleware(query)) + scimMiddleware := zhttp_middlware.ChainedWithErrorHandler(serrors.ErrorHandler, middlewares...) + mapResource(router, scimMiddleware, sresources.NewUsersHandler(command, query, userCodeAlg, cfg)) + return router +} + +func mapResource[T sresources.ResourceHolder](router *mux.Router, mw zhttp_middlware.ErrorHandlerFunc, handler sresources.ResourceHandler[T]) { + adapter := sresources.NewResourceHandlerAdapter[T](handler) + resourceRouter := router.PathPrefix("/" + path.Join(zhttp.OrgIdInPathVariable, string(handler.ResourceNamePlural()))).Subrouter() + + resourceRouter.Handle("", mw(handleResourceCreatedResponse(adapter.Create))).Methods(http.MethodPost) +} + +func handleResourceCreatedResponse[T sresources.ResourceHolder](next func(*http.Request) (T, error)) zhttp_middlware.HandlerFuncWithError { + return func(w http.ResponseWriter, r *http.Request) error { + entity, err := next(r) + if err != nil { + return err + } + + resource := entity.GetResource() + w.Header().Set(zhttp.Location, resource.Meta.Location) + w.WriteHeader(http.StatusCreated) + + err = json.NewEncoder(w).Encode(entity) + logging.OnError(err).Warn("scim json response encoding failed") + return nil + } +} diff --git a/internal/integration/assert.go b/internal/integration/assert.go index 6743c8297e..3f5ebdf54f 100644 --- a/internal/integration/assert.go +++ b/internal/integration/assert.go @@ -6,6 +6,8 @@ import ( "github.com/pmezard/go-difflib/difflib" "github.com/stretchr/testify/assert" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/timestamppb" @@ -128,6 +130,13 @@ func AssertResourceListDetails[D ResourceListDetailsMsg](t assert.TestingT, expe } } +func AssertGrpcStatus(t assert.TestingT, expected codes.Code, err error) { + assert.Error(t, err) + statusErr, ok := status.FromError(err) + assert.True(t, ok) + assert.Equal(t, expected, statusErr.Code()) +} + // EqualProto is inspired by [assert.Equal], only that it tests equality of a proto message. // A message diff is printed on the error test log if the messages are not equal. // @@ -160,3 +169,9 @@ func diffProto(expected, actual proto.Message) string { } return "\n\nDiff:\n" + diff } + +func AssertMapContains[M ~map[K]V, K comparable, V any](t *testing.T, m M, key K, expectedValue V) { + val, exists := m[key] + assert.True(t, exists, "Key '%s' should exist in the map", key) + assert.Equal(t, expectedValue, val, "Key '%s' should have value '%d'", key, expectedValue) +} diff --git a/internal/integration/client.go b/internal/integration/client.go index af30f0e642..d18c2d9b12 100644 --- a/internal/integration/client.go +++ b/internal/integration/client.go @@ -17,6 +17,7 @@ import ( "google.golang.org/protobuf/types/known/structpb" "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/integration/scim" "github.com/zitadel/zitadel/pkg/grpc/admin" "github.com/zitadel/zitadel/pkg/grpc/auth" "github.com/zitadel/zitadel/pkg/grpc/feature/v2" @@ -67,6 +68,7 @@ type Client struct { IDPv2 idp_pb.IdentityProviderServiceClient UserV3Alpha user_v3alpha.ZITADELUsersClient SAMLv2 saml_pb.SAMLServiceClient + SCIM *scim.Client } func newClient(ctx context.Context, target string) (*Client, error) { @@ -99,6 +101,7 @@ func newClient(ctx context.Context, target string) (*Client, error) { IDPv2: idp_pb.NewIdentityProviderServiceClient(cc), UserV3Alpha: user_v3alpha.NewZITADELUsersClient(cc), SAMLv2: saml_pb.NewSAMLServiceClient(cc), + SCIM: scim.NewScimClient(target), } return client, client.pollHealth(ctx) } diff --git a/internal/integration/scim/client.go b/internal/integration/scim/client.go new file mode 100644 index 0000000000..d6b5066f45 --- /dev/null +++ b/internal/integration/scim/client.go @@ -0,0 +1,133 @@ +package scim + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "path" + + "github.com/zitadel/logging" + "google.golang.org/grpc/metadata" + + zhttp "github.com/zitadel/zitadel/internal/api/http" + "github.com/zitadel/zitadel/internal/api/scim/middleware" + "github.com/zitadel/zitadel/internal/api/scim/resources" + "github.com/zitadel/zitadel/internal/api/scim/schemas" +) + +type Client struct { + Users *ResourceClient +} + +type ResourceClient struct { + client *http.Client + baseUrl string + resourceName string +} + +type ScimError struct { + Schemas []string `json:"schemas"` + ScimType string `json:"scimType"` + Detail string `json:"detail"` + Status string `json:"status"` + ZitadelDetail *ZitadelErrorDetail `json:"urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail,omitempty"` +} + +type ZitadelErrorDetail struct { + ID string `json:"id"` + Message string `json:"message"` +} + +func NewScimClient(target string) *Client { + target = "http://" + target + schemas.HandlerPrefix + client := &http.Client{} + return &Client{ + Users: &ResourceClient{ + client: client, + baseUrl: target, + resourceName: "Users", + }, + } +} + +func (c *ResourceClient) Create(ctx context.Context, orgID string, body []byte) (*resources.ScimUser, error) { + user := new(resources.ScimUser) + err := c.doWithBody(ctx, http.MethodPost, orgID, "", bytes.NewReader(body), user) + return user, err +} + +func (c *ResourceClient) doWithBody(ctx context.Context, method, orgID, url string, body io.Reader, responseEntity interface{}) error { + req, err := http.NewRequestWithContext(ctx, method, c.buildURL(orgID, url), body) + if err != nil { + return err + } + + req.Header.Set(zhttp.ContentType, middleware.ContentTypeScim) + return c.doReq(req, responseEntity) +} + +func (c *ResourceClient) doReq(req *http.Request, responseEntity interface{}) error { + addTokenAsHeader(req) + + resp, err := c.client.Do(req) + defer func() { + err := resp.Body.Close() + logging.OnError(err).Error("Failed to close response body") + }() + + if err != nil { + return err + } + + if (resp.StatusCode / 100) != 2 { + return readScimError(resp) + } + + if responseEntity == nil { + return nil + } + + err = readJson(responseEntity, resp) + return err +} + +func addTokenAsHeader(req *http.Request) { + md, ok := metadata.FromOutgoingContext(req.Context()) + if !ok { + return + } + + req.Header.Set("Authorization", md.Get("Authorization")[0]) +} + +func readJson(entity interface{}, resp *http.Response) error { + defer func(body io.ReadCloser) { + err := body.Close() + logging.OnError(err).Panic("Failed to close response body") + }(resp.Body) + + err := json.NewDecoder(resp.Body).Decode(entity) + logging.OnError(err).Panic("Failed decoding entity") + return err +} + +func readScimError(resp *http.Response) error { + scimErr := new(ScimError) + readErr := readJson(scimErr, resp) + logging.OnError(readErr).Panic("Failed reading scim error") + return scimErr +} + +func (c *ResourceClient) buildURL(orgID, segment string) string { + if segment == "" { + return c.baseUrl + "/" + path.Join(orgID, c.resourceName) + } + + return c.baseUrl + "/" + path.Join(orgID, c.resourceName, segment) +} + +func (err *ScimError) Error() string { + return "scim error: " + err.Detail +} diff --git a/internal/zerrors/zerror.go b/internal/zerrors/zerror.go index d7b85b84a7..996f67ce29 100644 --- a/internal/zerrors/zerror.go +++ b/internal/zerrors/zerror.go @@ -79,3 +79,8 @@ func (err *ZitadelError) As(target interface{}) bool { reflect.Indirect(reflect.ValueOf(target)).Set(reflect.ValueOf(err)) return true } + +func IsZitadelError(err error) bool { + zitadelErr := new(ZitadelError) + return errors.As(err, &zitadelErr) +} diff --git a/internal/zerrors/zerror_test.go b/internal/zerrors/zerror_test.go index 3a11a8e78e..517f938ee4 100644 --- a/internal/zerrors/zerror_test.go +++ b/internal/zerrors/zerror_test.go @@ -1,6 +1,7 @@ package zerrors_test import ( + "errors" "testing" "github.com/stretchr/testify/assert" @@ -17,3 +18,27 @@ func TestErrorMethod(t *testing.T) { subExptected := "ID=subID Message=subMsg Parent=(ID=id Message=msg)" assert.Equal(t, subExptected, err.Error()) } + +func TestIsZitadelError(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + { + name: "zitadel error", + err: zerrors.ThrowInvalidArgument(nil, "id", "msg"), + want: true, + }, + { + name: "other error", + err: errors.New("just a random error"), + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equalf(t, tt.want, zerrors.IsZitadelError(tt.err), "IsZitadelError(%v)", tt.err) + }) + } +} From af09e51b1eeddefa524f94424c431f4ac9b016d5 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 9 Jan 2025 15:12:13 +0100 Subject: [PATCH 13/30] feat: delete user scim v2 endpoint (#9151) # Which Problems Are Solved - Adds support for the user delete SCIM v2 endpoint # How the Problems Are Solved - Adds support for the user delete SCIM v2 endpoint under `DELETE /scim/v2/{orgID}/Users/{id}` # Additional Context Part of #8140 --- internal/api/scim/authz.go | 3 + .../integration_test/users_create_test.go | 30 +++--- .../integration_test/users_delete_test.go | 84 +++++++++++++++ .../api/scim/resources/resource_handler.go | 1 + .../resources/resource_handler_adapter.go | 7 ++ internal/api/scim/resources/user.go | 102 ++++++++++++------ internal/api/scim/resources/user_mapping.go | 52 +++++++++ internal/api/scim/server.go | 13 +++ internal/integration/scim/assertions.go | 22 ++++ internal/integration/scim/client.go | 13 +++ 10 files changed, 277 insertions(+), 50 deletions(-) create mode 100644 internal/api/scim/integration_test/users_delete_test.go create mode 100644 internal/integration/scim/assertions.go diff --git a/internal/api/scim/authz.go b/internal/api/scim/authz.go index 759ee3e84d..a89df38061 100644 --- a/internal/api/scim/authz.go +++ b/internal/api/scim/authz.go @@ -10,4 +10,7 @@ var AuthMapping = authz.MethodMapping{ "POST:/scim/v2/" + http.OrgIdInPathVariable + "/Users": { Permission: domain.PermissionUserWrite, }, + "DELETE:/scim/v2/" + http.OrgIdInPathVariable + "/Users/{id}": { + Permission: domain.PermissionUserDelete, + }, } diff --git a/internal/api/scim/integration_test/users_create_test.go b/internal/api/scim/integration_test/users_create_test.go index cad0fcbd33..b7d97e342f 100644 --- a/internal/api/scim/integration_test/users_create_test.go +++ b/internal/api/scim/integration_test/users_create_test.go @@ -5,7 +5,7 @@ package integration_test import ( "context" _ "embed" - "errors" + "github.com/brianvoe/gofakeit/v6" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/zitadel/zitadel/internal/api/scim/schemas" @@ -16,7 +16,6 @@ import ( "google.golang.org/grpc/codes" "net/http" "path" - "strconv" "testing" ) @@ -139,20 +138,14 @@ func TestCreateUser(t *testing.T) { } if err != nil { - assert.IsType(t, new(scim.ScimError), err) - - var scimErr *scim.ScimError - errors.As(err, &scimErr) - assert.Equal(t, tt.scimErrorType, scimErr.ScimType) - statusCode := tt.errorStatus if statusCode == 0 { statusCode = http.StatusBadRequest } - assert.Equal(t, strconv.Itoa(statusCode), scimErr.Status) - + scimErr := scim.RequireScimError(t, statusCode, err) + assert.Equal(t, tt.scimErrorType, scimErr.Error.ScimType) if tt.zitadelErrID != "" { - assert.Equal(t, tt.zitadelErrID, scimErr.ZitadelDetail.ID) + assert.Equal(t, tt.zitadelErrID, scimErr.Error.ZitadelDetail.ID) } return @@ -175,13 +168,8 @@ func TestCreateUser_duplicate(t *testing.T) { require.NoError(t, err) _, err = Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, minimalUserJson) - require.Error(t, err) - assert.IsType(t, new(scim.ScimError), err) - - var scimErr *scim.ScimError - errors.As(err, &scimErr) - assert.Equal(t, strconv.Itoa(http.StatusConflict), scimErr.Status) - assert.Equal(t, "User already exists", scimErr.Detail) + scimErr := scim.RequireScimError(t, http.StatusConflict, err) + assert.Equal(t, "User already exists", scimErr.Error.Detail) _, err = Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: createdUser.ID}) require.NoError(t, err) @@ -248,3 +236,9 @@ func TestCreateUser_scopedExternalID(t *testing.T) { _, err = Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: createdUser.ID}) require.NoError(t, err) } + +func TestCreateUser_anotherOrg(t *testing.T) { + org := Instance.CreateOrganization(Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner), gofakeit.Name(), gofakeit.Email()) + _, err := Instance.Client.SCIM.Users.Create(CTX, org.OrganizationId, fullUserJson) + scim.RequireScimError(t, http.StatusNotFound, err) +} diff --git a/internal/api/scim/integration_test/users_delete_test.go b/internal/api/scim/integration_test/users_delete_test.go new file mode 100644 index 0000000000..6d3f73a71e --- /dev/null +++ b/internal/api/scim/integration_test/users_delete_test.go @@ -0,0 +1,84 @@ +//go:build integration + +package integration_test + +import ( + "context" + "github.com/brianvoe/gofakeit/v6" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/internal/integration/scim" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" + "google.golang.org/grpc/codes" + "net/http" + "testing" +) + +func TestDeleteUser_errors(t *testing.T) { + tests := []struct { + name string + ctx context.Context + errorStatus int + }{ + { + name: "not authenticated", + ctx: context.Background(), + errorStatus: http.StatusUnauthorized, + }, + { + name: "no permissions", + ctx: Instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + errorStatus: http.StatusNotFound, + }, + { + name: "unknown user id", + errorStatus: http.StatusNotFound, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := tt.ctx + if ctx == nil { + ctx = CTX + } + + err := Instance.Client.SCIM.Users.Delete(ctx, Instance.DefaultOrg.Id, "1") + + statusCode := tt.errorStatus + if statusCode == 0 { + statusCode = http.StatusBadRequest + } + + scim.RequireScimError(t, statusCode, err) + }) + } +} + +func TestDeleteUser_ensureReallyDeleted(t *testing.T) { + // create user and dependencies + createUserResp := Instance.CreateHumanUser(CTX) + proj, err := Instance.CreateProject(CTX) + require.NoError(t, err) + + Instance.CreateProjectUserGrant(t, CTX, proj.Id, createUserResp.UserId) + + // delete user via scim + err = Instance.Client.SCIM.Users.Delete(CTX, Instance.DefaultOrg.Id, createUserResp.UserId) + assert.NoError(t, err) + + // ensure it is really deleted => try to delete again => should 404 + err = Instance.Client.SCIM.Users.Delete(CTX, Instance.DefaultOrg.Id, createUserResp.UserId) + scim.RequireScimError(t, http.StatusNotFound, err) + + // try to get user via api => should 404 + _, err = Instance.Client.UserV2.GetUserByID(CTX, &user.GetUserByIDRequest{UserId: createUserResp.UserId}) + integration.AssertGrpcStatus(t, codes.NotFound, err) +} + +func TestDeleteUser_anotherOrg(t *testing.T) { + createUserResp := Instance.CreateHumanUser(CTX) + org := Instance.CreateOrganization(Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner), gofakeit.Name(), gofakeit.Email()) + err := Instance.Client.SCIM.Users.Delete(CTX, org.OrganizationId, createUserResp.UserId) + scim.RequireScimError(t, http.StatusNotFound, err) +} diff --git a/internal/api/scim/resources/resource_handler.go b/internal/api/scim/resources/resource_handler.go index c624253ee9..2d601fd1fc 100644 --- a/internal/api/scim/resources/resource_handler.go +++ b/internal/api/scim/resources/resource_handler.go @@ -19,6 +19,7 @@ type ResourceHandler[T ResourceHolder] interface { NewResource() T Create(ctx context.Context, resource T) (T, error) + Delete(ctx context.Context, id string) error } type Resource struct { diff --git a/internal/api/scim/resources/resource_handler_adapter.go b/internal/api/scim/resources/resource_handler_adapter.go index 79f74f7bfb..979fdad99a 100644 --- a/internal/api/scim/resources/resource_handler_adapter.go +++ b/internal/api/scim/resources/resource_handler_adapter.go @@ -5,6 +5,8 @@ import ( "net/http" "slices" + "github.com/gorilla/mux" + "github.com/zitadel/zitadel/internal/api/scim/schemas" "github.com/zitadel/zitadel/internal/api/scim/serrors" "github.com/zitadel/zitadel/internal/zerrors" @@ -45,6 +47,11 @@ func (adapter *ResourceHandlerAdapter[T]) Create(r *http.Request) (T, error) { return adapter.handler.Create(r.Context(), entity) } +func (adapter *ResourceHandlerAdapter[T]) Delete(r *http.Request) error { + id := mux.Vars(r)["id"] + return adapter.handler.Delete(r.Context(), id) +} + func (adapter *ResourceHandlerAdapter[T]) readEntityFromBody(r *http.Request) (T, error) { entity := adapter.handler.NewResource() err := json.NewDecoder(r.Body).Decode(entity) diff --git a/internal/api/scim/resources/user.go b/internal/api/scim/resources/user.go index fef9a34c6c..14f5af6115 100644 --- a/internal/api/scim/resources/user.go +++ b/internal/api/scim/resources/user.go @@ -7,7 +7,7 @@ import ( "github.com/zitadel/zitadel/internal/api/authz" scim_config "github.com/zitadel/zitadel/internal/api/scim/config" - schemas2 "github.com/zitadel/zitadel/internal/api/scim/schemas" + scim_schemas "github.com/zitadel/zitadel/internal/api/scim/schemas" "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/query" @@ -22,26 +22,26 @@ type UsersHandler struct { type ScimUser struct { *Resource - ID string `json:"id"` - ExternalID string `json:"externalId,omitempty"` - UserName string `json:"userName,omitempty"` - Name *ScimUserName `json:"name,omitempty"` - DisplayName string `json:"displayName,omitempty"` - NickName string `json:"nickName,omitempty"` - ProfileUrl *schemas2.HttpURL `json:"profileUrl,omitempty"` - Title string `json:"title,omitempty"` - PreferredLanguage language.Tag `json:"preferredLanguage,omitempty"` - Locale string `json:"locale,omitempty"` - Timezone string `json:"timezone,omitempty"` - Active bool `json:"active,omitempty"` - Emails []*ScimEmail `json:"emails,omitempty"` - PhoneNumbers []*ScimPhoneNumber `json:"phoneNumbers,omitempty"` - Password *schemas2.WriteOnlyString `json:"password,omitempty"` - Ims []*ScimIms `json:"ims,omitempty"` - Addresses []*ScimAddress `json:"addresses,omitempty"` - Photos []*ScimPhoto `json:"photos,omitempty"` - Entitlements []*ScimEntitlement `json:"entitlements,omitempty"` - Roles []*ScimRole `json:"roles,omitempty"` + ID string `json:"id"` + ExternalID string `json:"externalId,omitempty"` + UserName string `json:"userName,omitempty"` + Name *ScimUserName `json:"name,omitempty"` + DisplayName string `json:"displayName,omitempty"` + NickName string `json:"nickName,omitempty"` + ProfileUrl *scim_schemas.HttpURL `json:"profileUrl,omitempty"` + Title string `json:"title,omitempty"` + PreferredLanguage language.Tag `json:"preferredLanguage,omitempty"` + Locale string `json:"locale,omitempty"` + Timezone string `json:"timezone,omitempty"` + Active *bool `json:"active,omitempty"` + Emails []*ScimEmail `json:"emails,omitempty"` + PhoneNumbers []*ScimPhoneNumber `json:"phoneNumbers,omitempty"` + Password *scim_schemas.WriteOnlyString `json:"password,omitempty"` + Ims []*ScimIms `json:"ims,omitempty"` + Addresses []*ScimAddress `json:"addresses,omitempty"` + Photos []*ScimPhoto `json:"photos,omitempty"` + Entitlements []*ScimEntitlement `json:"entitlements,omitempty"` + Roles []*ScimRole `json:"roles,omitempty"` } type ScimEntitlement struct { @@ -59,10 +59,10 @@ type ScimRole struct { } type ScimPhoto struct { - Value schemas2.HttpURL `json:"value"` - Display string `json:"display,omitempty"` - Type string `json:"type"` - Primary bool `json:"primary,omitempty"` + Value scim_schemas.HttpURL `json:"value"` + Display string `json:"display,omitempty"` + Type string `json:"type"` + Primary bool `json:"primary,omitempty"` } type ScimAddress struct { @@ -108,12 +108,12 @@ func NewUsersHandler( return &UsersHandler{command, query, userCodeAlg, config} } -func (h *UsersHandler) ResourceNameSingular() schemas2.ScimResourceTypeSingular { - return schemas2.UserResourceType +func (h *UsersHandler) ResourceNameSingular() scim_schemas.ScimResourceTypeSingular { + return scim_schemas.UserResourceType } -func (h *UsersHandler) ResourceNamePlural() schemas2.ScimResourceTypePlural { - return schemas2.UsersResourceType +func (h *UsersHandler) ResourceNamePlural() scim_schemas.ScimResourceTypePlural { + return scim_schemas.UsersResourceType } func (u *ScimUser) GetResource() *Resource { @@ -124,8 +124,8 @@ func (h *UsersHandler) NewResource() *ScimUser { return new(ScimUser) } -func (h *UsersHandler) SchemaType() schemas2.ScimSchemaType { - return schemas2.IdUser +func (h *UsersHandler) SchemaType() scim_schemas.ScimSchemaType { + return scim_schemas.IdUser } func (h *UsersHandler) Create(ctx context.Context, user *ScimUser) (*ScimUser, error) { @@ -142,5 +142,43 @@ func (h *UsersHandler) Create(ctx context.Context, user *ScimUser) (*ScimUser, e user.ID = addHuman.Details.ID user.Resource = buildResource(ctx, h, addHuman.Details) - return user, err + return user, nil +} + +func (h *UsersHandler) Delete(ctx context.Context, id string) error { + memberships, grants, err := h.queryUserDependencies(ctx, id) + if err != nil { + return err + } + + _, err = h.command.RemoveUserV2(ctx, id, memberships, grants...) + return err +} + +func (h *UsersHandler) queryUserDependencies(ctx context.Context, userID string) ([]*command.CascadingMembership, []string, error) { + userGrantUserQuery, err := query.NewUserGrantUserIDSearchQuery(userID) + if err != nil { + return nil, nil, err + } + + grants, err := h.query.UserGrants(ctx, &query.UserGrantsQueries{ + Queries: []query.SearchQuery{userGrantUserQuery}, + }, true) + if err != nil { + return nil, nil, err + } + + membershipsUserQuery, err := query.NewMembershipUserIDQuery(userID) + if err != nil { + return nil, nil, err + } + + memberships, err := h.query.Memberships(ctx, &query.MembershipSearchQuery{ + Queries: []query.SearchQuery{membershipsUserQuery}, + }, false) + + if err != nil { + return nil, nil, err + } + return cascadingMemberships(memberships.Memberships), userGrantsToIDs(grants.UserGrants), nil } diff --git a/internal/api/scim/resources/user_mapping.go b/internal/api/scim/resources/user_mapping.go index 8ee7cd511c..bc40005382 100644 --- a/internal/api/scim/resources/user_mapping.go +++ b/internal/api/scim/resources/user_mapping.go @@ -7,6 +7,7 @@ import ( "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" ) func (h *UsersHandler) mapToAddHuman(ctx context.Context, scimUser *ScimUser) (*command.AddHuman, error) { @@ -79,3 +80,54 @@ func (h *UsersHandler) mapPrimaryPhone(scimUser *ScimUser) command.Phone { return command.Phone{} } + +func cascadingMemberships(memberships []*query.Membership) []*command.CascadingMembership { + cascades := make([]*command.CascadingMembership, len(memberships)) + for i, membership := range memberships { + cascades[i] = &command.CascadingMembership{ + UserID: membership.UserID, + ResourceOwner: membership.ResourceOwner, + IAM: cascadingIAMMembership(membership.IAM), + Org: cascadingOrgMembership(membership.Org), + Project: cascadingProjectMembership(membership.Project), + ProjectGrant: cascadingProjectGrantMembership(membership.ProjectGrant), + } + } + return cascades +} + +func cascadingIAMMembership(membership *query.IAMMembership) *command.CascadingIAMMembership { + if membership == nil { + return nil + } + return &command.CascadingIAMMembership{IAMID: membership.IAMID} +} + +func cascadingOrgMembership(membership *query.OrgMembership) *command.CascadingOrgMembership { + if membership == nil { + return nil + } + return &command.CascadingOrgMembership{OrgID: membership.OrgID} +} + +func cascadingProjectMembership(membership *query.ProjectMembership) *command.CascadingProjectMembership { + if membership == nil { + return nil + } + return &command.CascadingProjectMembership{ProjectID: membership.ProjectID} +} + +func cascadingProjectGrantMembership(membership *query.ProjectGrantMembership) *command.CascadingProjectGrantMembership { + if membership == nil { + return nil + } + return &command.CascadingProjectGrantMembership{ProjectID: membership.ProjectID, GrantID: membership.GrantID} +} + +func userGrantsToIDs(userGrants []*query.UserGrant) []string { + converted := make([]string, len(userGrants)) + for i, grant := range userGrants { + converted[i] = grant.ID + } + return converted +} diff --git a/internal/api/scim/server.go b/internal/api/scim/server.go index c23aadf247..a2f9c7e7bf 100644 --- a/internal/api/scim/server.go +++ b/internal/api/scim/server.go @@ -54,6 +54,7 @@ func mapResource[T sresources.ResourceHolder](router *mux.Router, mw zhttp_middl resourceRouter := router.PathPrefix("/" + path.Join(zhttp.OrgIdInPathVariable, string(handler.ResourceNamePlural()))).Subrouter() resourceRouter.Handle("", mw(handleResourceCreatedResponse(adapter.Create))).Methods(http.MethodPost) + resourceRouter.Handle("/{id}", mw(handleEmptyResponse(adapter.Delete))).Methods(http.MethodDelete) } func handleResourceCreatedResponse[T sresources.ResourceHolder](next func(*http.Request) (T, error)) zhttp_middlware.HandlerFuncWithError { @@ -72,3 +73,15 @@ func handleResourceCreatedResponse[T sresources.ResourceHolder](next func(*http. return nil } } + +func handleEmptyResponse(next func(*http.Request) error) zhttp_middlware.HandlerFuncWithError { + return func(w http.ResponseWriter, r *http.Request) error { + err := next(r) + if err != nil { + return err + } + + w.WriteHeader(http.StatusNoContent) + return nil + } +} diff --git a/internal/integration/scim/assertions.go b/internal/integration/scim/assertions.go new file mode 100644 index 0000000000..a91c33da82 --- /dev/null +++ b/internal/integration/scim/assertions.go @@ -0,0 +1,22 @@ +package scim + +import ( + "errors" + "strconv" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type AssertedScimError struct { + Error *ScimError +} + +func RequireScimError(t require.TestingT, httpStatus int, err error) AssertedScimError { + require.Error(t, err) + + var scimErr *ScimError + assert.True(t, errors.As(err, &scimErr)) + assert.Equal(t, strconv.Itoa(httpStatus), scimErr.Status) + return AssertedScimError{scimErr} // wrap it, otherwise error handling is enforced +} diff --git a/internal/integration/scim/client.go b/internal/integration/scim/client.go index d6b5066f45..478c831826 100644 --- a/internal/integration/scim/client.go +++ b/internal/integration/scim/client.go @@ -58,6 +58,19 @@ func (c *ResourceClient) Create(ctx context.Context, orgID string, body []byte) return user, err } +func (c *ResourceClient) Delete(ctx context.Context, orgID, id string) error { + return c.do(ctx, http.MethodDelete, orgID, id) +} + +func (c *ResourceClient) do(ctx context.Context, method, orgID, url string) error { + req, err := http.NewRequestWithContext(ctx, method, c.buildURL(orgID, url), nil) + if err != nil { + return err + } + + return c.doReq(req, nil) +} + func (c *ResourceClient) doWithBody(ctx context.Context, method, orgID, url string, body io.Reader, responseEntity interface{}) error { req, err := http.NewRequestWithContext(ctx, method, c.buildURL(orgID, url), body) if err != nil { From b0bcb051fcee9903ccce31c7d5afae7ee44f3ce3 Mon Sep 17 00:00:00 2001 From: Denis Dvornikov Date: Fri, 10 Jan 2025 11:30:26 +0100 Subject: [PATCH 14/30] docs: update external-login.mdx according to api spec (#9058) # Which Problems Are Solved Documentation update # Additional Context The guide is outdate, a few fields from the given example confuse and must be update according to the api spec: https://zitadel.com/docs/apis/resources/user_service_v2/user-service-add-human-user --------- Co-authored-by: Livio Spring --- docs/docs/guides/integrate/login-ui/external-login.mdx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/docs/guides/integrate/login-ui/external-login.mdx b/docs/docs/guides/integrate/login-ui/external-login.mdx index dde966d388..3b3c47cf18 100644 --- a/docs/docs/guides/integrate/login-ui/external-login.mdx +++ b/docs/docs/guides/integrate/login-ui/external-login.mdx @@ -152,7 +152,7 @@ curl --request POST \ If you didn't get a user ID in the parameters of your success page, you know that there is no existing user in ZITADEL with that provider, and you can register a new user or link it to an existing account (read the next section). Fill the IdP links in the create user request to add a user with an external login provider. -The idpId is the ID of the provider in ZITADEL, the idpExternalId is the ID of the user in the external identity provider; usually, this is sent in the “sub”. +The idpId is the ID of the provider in ZITADEL, the userId is the ID of the user in the external identity provider; usually, this is sent in the “sub”. The display name is used to list the linkings on the users. [Create User API Documentation](/docs/apis/resources/user_service_v2/user-service-add-human-user) @@ -181,8 +181,8 @@ curl --request POST \ "idpLinks": [ { "idpId": "218528353504723201", - "idpExternalId": "111392805975715856637", - "displayName": "Minnie Mouse" + "userId": "111392805975715856637", + "userName": "Minnie Mouse" } ] }' @@ -205,8 +205,8 @@ curl --request POST \ --data '{ "idpLink": { "idpId": "218528353504723201", - "idpExternalId": "1113928059757158566371", - "displayName": "Minnie Mouse" + "userId": "1113928059757158566371", + "userName": "Minnie Mouse" } }' ``` From 9c7f2a7d50e689104929e8fcda86ea8afc3ba26d Mon Sep 17 00:00:00 2001 From: Lars Date: Fri, 10 Jan 2025 12:15:06 +0100 Subject: [PATCH 15/30] feat: get user scim v2 endpoint (#9161) # Which Problems Are Solved - Adds support for the get user SCIM v2 endpoint # How the Problems Are Solved - Adds support for the get user SCIM v2 endpoint under `GET /scim/v2/{orgID}/Users/{id}` # Additional Context Part of #8140 Replaces https://github.com/zitadel/zitadel/pull/9154 as requested by the maintainers, discussions see https://github.com/zitadel/zitadel/pull/9154. --- internal/api/scim/authz.go | 3 + .../scim/integration_test/users_get_test.go | 255 ++++++++++++++++++ .../api/scim/resources/resource_handler.go | 1 + .../resources/resource_handler_adapter.go | 5 + internal/api/scim/resources/user.go | 13 + internal/api/scim/resources/user_mapping.go | 112 ++++++++ internal/api/scim/resources/user_metadata.go | 85 +++++- internal/api/scim/server.go | 17 ++ internal/integration/assert.go | 87 ++++++ internal/integration/assert_test.go | 150 +++++++++++ internal/integration/scim/client.go | 31 ++- 11 files changed, 744 insertions(+), 15 deletions(-) create mode 100644 internal/api/scim/integration_test/users_get_test.go diff --git a/internal/api/scim/authz.go b/internal/api/scim/authz.go index a89df38061..26b4ecf10f 100644 --- a/internal/api/scim/authz.go +++ b/internal/api/scim/authz.go @@ -10,6 +10,9 @@ var AuthMapping = authz.MethodMapping{ "POST:/scim/v2/" + http.OrgIdInPathVariable + "/Users": { Permission: domain.PermissionUserWrite, }, + "GET:/scim/v2/" + http.OrgIdInPathVariable + "/Users/{id}": { + Permission: domain.PermissionUserRead, + }, "DELETE:/scim/v2/" + http.OrgIdInPathVariable + "/Users/{id}": { Permission: domain.PermissionUserDelete, }, diff --git a/internal/api/scim/integration_test/users_get_test.go b/internal/api/scim/integration_test/users_get_test.go new file mode 100644 index 0000000000..4506de1ecf --- /dev/null +++ b/internal/api/scim/integration_test/users_get_test.go @@ -0,0 +1,255 @@ +//go:build integration + +package integration_test + +import ( + "context" + "github.com/brianvoe/gofakeit/v6" + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zitadel/zitadel/internal/api/scim/resources" + "github.com/zitadel/zitadel/internal/api/scim/schemas" + "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/internal/integration/scim" + "github.com/zitadel/zitadel/pkg/grpc/management" + guser "github.com/zitadel/zitadel/pkg/grpc/user/v2" + "golang.org/x/text/language" + "net/http" + "path" + "testing" +) + +func TestGetUser(t *testing.T) { + tests := []struct { + name string + buildUserID func() (userID string, deleteUser bool) + ctx context.Context + want *resources.ScimUser + wantErr bool + errorStatus int + }{ + { + name: "not authenticated", + ctx: context.Background(), + errorStatus: http.StatusUnauthorized, + wantErr: true, + }, + { + name: "no permissions", + ctx: Instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + errorStatus: http.StatusNotFound, + wantErr: true, + }, + { + name: "unknown user id", + buildUserID: func() (string, bool) { + return "unknown", false + }, + errorStatus: http.StatusNotFound, + wantErr: true, + }, + { + name: "created via grpc", + want: &resources.ScimUser{ + Name: &resources.ScimUserName{ + FamilyName: "Mouse", + GivenName: "Mickey", + }, + PreferredLanguage: language.MustParse("nl"), + PhoneNumbers: []*resources.ScimPhoneNumber{ + { + Value: "+41791234567", + Primary: true, + }, + }, + }, + }, + { + name: "created via scim", + buildUserID: func() (string, bool) { + user, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, fullUserJson) + require.NoError(t, err) + return user.ID, true + }, + want: &resources.ScimUser{ + ExternalID: "701984", + UserName: "bjensen@example.com", + Name: &resources.ScimUserName{ + Formatted: "Babs Jensen", // DisplayName takes precedence + FamilyName: "Jensen", + GivenName: "Barbara", + MiddleName: "Jane", + HonorificPrefix: "Ms.", + HonorificSuffix: "III", + }, + DisplayName: "Babs Jensen", + NickName: "Babs", + ProfileUrl: integration.Must(schemas.ParseHTTPURL("http://login.example.com/bjensen")), + Title: "Tour Guide", + PreferredLanguage: language.Make("en-US"), + Locale: "en-US", + Timezone: "America/Los_Angeles", + Active: gu.Ptr(true), + Emails: []*resources.ScimEmail{ + { + Value: "bjensen@example.com", + Primary: true, + }, + }, + PhoneNumbers: []*resources.ScimPhoneNumber{ + { + Value: "+415555555555", + Primary: true, + }, + }, + Ims: []*resources.ScimIms{ + { + Value: "someaimhandle", + Type: "aim", + }, + { + Value: "twitterhandle", + Type: "X", + }, + }, + Addresses: []*resources.ScimAddress{ + { + Type: "work", + StreetAddress: "100 Universal City Plaza", + Locality: "Hollywood", + Region: "CA", + PostalCode: "91608", + Country: "USA", + Formatted: "100 Universal City Plaza\nHollywood, CA 91608 USA", + Primary: true, + }, + { + Type: "home", + StreetAddress: "456 Hollywood Blvd", + Locality: "Hollywood", + Region: "CA", + PostalCode: "91608", + Country: "USA", + Formatted: "456 Hollywood Blvd\nHollywood, CA 91608 USA", + }, + }, + Photos: []*resources.ScimPhoto{ + { + Value: *integration.Must(schemas.ParseHTTPURL("https://photos.example.com/profilephoto/72930000000Ccne/F")), + Type: "photo", + }, + { + Value: *integration.Must(schemas.ParseHTTPURL("https://photos.example.com/profilephoto/72930000000Ccne/T")), + Type: "thumbnail", + }, + }, + Roles: []*resources.ScimRole{ + { + Value: "my-role-1", + Display: "Rolle 1", + Type: "main-role", + Primary: true, + }, + { + Value: "my-role-2", + Display: "Rolle 2", + Type: "secondary-role", + Primary: false, + }, + }, + Entitlements: []*resources.ScimEntitlement{ + { + Value: "my-entitlement-1", + Display: "Entitlement 1", + Type: "main-entitlement", + Primary: true, + }, + { + Value: "my-entitlement-2", + Display: "Entitlement 2", + Type: "secondary-entitlement", + Primary: false, + }, + }, + }, + }, + { + name: "scoped externalID", + buildUserID: func() (string, bool) { + // create user without provisioning domain + user, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, fullUserJson) + require.NoError(t, err) + + // set provisioning domain of service user + _, err = Instance.Client.Mgmt.SetUserMetadata(CTX, &management.SetUserMetadataRequest{ + Id: Instance.Users.Get(integration.UserTypeOrgOwner).ID, + Key: "urn:zitadel:scim:provisioning_domain", + Value: []byte("fooBar"), + }) + require.NoError(t, err) + + // set externalID for provisioning domain + _, err = Instance.Client.Mgmt.SetUserMetadata(CTX, &management.SetUserMetadataRequest{ + Id: user.ID, + Key: "urn:zitadel:scim:fooBar:externalId", + Value: []byte("100-scopedExternalId"), + }) + require.NoError(t, err) + return user.ID, true + }, + want: &resources.ScimUser{ + ExternalID: "100-scopedExternalId", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := tt.ctx + if ctx == nil { + ctx = CTX + } + + var userID string + var deleteUserAfterTest bool + if tt.buildUserID != nil { + userID, deleteUserAfterTest = tt.buildUserID() + } else { + createUserResp := Instance.CreateHumanUser(CTX) + userID = createUserResp.UserId + } + + user, err := Instance.Client.SCIM.Users.Get(ctx, Instance.DefaultOrg.Id, userID) + if tt.wantErr { + statusCode := tt.errorStatus + if statusCode == 0 { + statusCode = http.StatusBadRequest + } + + scim.RequireScimError(t, statusCode, err) + return + } + + assert.Equal(t, userID, user.ID) + assert.EqualValues(t, []schemas.ScimSchemaType{"urn:ietf:params:scim:schemas:core:2.0:User"}, user.Schemas) + assert.Equal(t, schemas.ScimResourceTypeSingular("User"), user.Resource.Meta.ResourceType) + assert.Equal(t, "http://"+Instance.Host()+path.Join(schemas.HandlerPrefix, Instance.DefaultOrg.Id, "Users", user.ID), user.Resource.Meta.Location) + assert.Nil(t, user.Password) + if !integration.PartiallyDeepEqual(tt.want, user) { + t.Errorf("keysFromArgs() got = %v, want %v", user, tt.want) + } + + if deleteUserAfterTest { + _, err = Instance.Client.UserV2.DeleteUser(CTX, &guser.DeleteUserRequest{UserId: user.ID}) + require.NoError(t, err) + } + }) + } +} + +func TestGetUser_anotherOrg(t *testing.T) { + createUserResp := Instance.CreateHumanUser(CTX) + org := Instance.CreateOrganization(Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner), gofakeit.Name(), gofakeit.Email()) + _, err := Instance.Client.SCIM.Users.Get(CTX, org.OrganizationId, createUserResp.UserId) + scim.RequireScimError(t, http.StatusNotFound, err) +} diff --git a/internal/api/scim/resources/resource_handler.go b/internal/api/scim/resources/resource_handler.go index 2d601fd1fc..93cb9adca0 100644 --- a/internal/api/scim/resources/resource_handler.go +++ b/internal/api/scim/resources/resource_handler.go @@ -20,6 +20,7 @@ type ResourceHandler[T ResourceHolder] interface { Create(ctx context.Context, resource T) (T, error) Delete(ctx context.Context, id string) error + Get(ctx context.Context, id string) (T, error) } type Resource struct { diff --git a/internal/api/scim/resources/resource_handler_adapter.go b/internal/api/scim/resources/resource_handler_adapter.go index 979fdad99a..bb362e9dfd 100644 --- a/internal/api/scim/resources/resource_handler_adapter.go +++ b/internal/api/scim/resources/resource_handler_adapter.go @@ -52,6 +52,11 @@ func (adapter *ResourceHandlerAdapter[T]) Delete(r *http.Request) error { return adapter.handler.Delete(r.Context(), id) } +func (adapter *ResourceHandlerAdapter[T]) Get(r *http.Request) (T, error) { + id := mux.Vars(r)["id"] + return adapter.handler.Get(r.Context(), id) +} + func (adapter *ResourceHandlerAdapter[T]) readEntityFromBody(r *http.Request) (T, error) { entity := adapter.handler.NewResource() err := json.NewDecoder(r.Body).Decode(entity) diff --git a/internal/api/scim/resources/user.go b/internal/api/scim/resources/user.go index 14f5af6115..5c9b35263f 100644 --- a/internal/api/scim/resources/user.go +++ b/internal/api/scim/resources/user.go @@ -155,6 +155,19 @@ func (h *UsersHandler) Delete(ctx context.Context, id string) error { return err } +func (h *UsersHandler) Get(ctx context.Context, id string) (*ScimUser, error) { + user, err := h.query.GetUserByID(ctx, false, id) + if err != nil { + return nil, err + } + + metadata, err := h.queryMetadataForUser(ctx, id) + if err != nil { + return nil, err + } + return h.mapToScimUser(ctx, user, metadata), nil +} + func (h *UsersHandler) queryUserDependencies(ctx context.Context, userID string) ([]*command.CascadingMembership, []string, error) { userGrantUserQuery, err := query.NewUserGrantUserIDSearchQuery(userID) if err != nil { diff --git a/internal/api/scim/resources/user_mapping.go b/internal/api/scim/resources/user_mapping.go index bc40005382..9726f1e190 100644 --- a/internal/api/scim/resources/user_mapping.go +++ b/internal/api/scim/resources/user_mapping.go @@ -2,9 +2,15 @@ package resources import ( "context" + "strconv" + "time" + "github.com/muhlemmer/gu" + "github.com/zitadel/logging" "golang.org/x/text/language" + "github.com/zitadel/zitadel/internal/api/scim/metadata" + "github.com/zitadel/zitadel/internal/api/scim/schemas" "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" @@ -81,6 +87,112 @@ func (h *UsersHandler) mapPrimaryPhone(scimUser *ScimUser) command.Phone { return command.Phone{} } +func (h *UsersHandler) mapToScimUser(ctx context.Context, user *query.User, md map[metadata.ScopedKey][]byte) *ScimUser { + scimUser := &ScimUser{ + Resource: h.buildResourceForQuery(ctx, user), + ID: user.ID, + ExternalID: extractScalarMetadata(ctx, md, metadata.KeyExternalId), + UserName: user.Username, + ProfileUrl: extractHttpURLMetadata(ctx, md, metadata.KeyProfileUrl), + Title: extractScalarMetadata(ctx, md, metadata.KeyTitle), + Locale: extractScalarMetadata(ctx, md, metadata.KeyLocale), + Timezone: extractScalarMetadata(ctx, md, metadata.KeyTimezone), + Active: gu.Ptr(user.State.IsEnabled()), + Ims: make([]*ScimIms, 0), + Addresses: make([]*ScimAddress, 0), + Photos: make([]*ScimPhoto, 0), + Entitlements: make([]*ScimEntitlement, 0), + Roles: make([]*ScimRole, 0), + } + + if scimUser.Locale != "" { + _, err := language.Parse(scimUser.Locale) + if err != nil { + logging.OnError(err).Warn("Failed to load locale of scim user") + scimUser.Locale = "" + } + } + + if scimUser.Timezone != "" { + _, err := time.LoadLocation(scimUser.Timezone) + if err != nil { + logging.OnError(err).Warn("Failed to load timezone of scim user") + scimUser.Timezone = "" + } + } + + if err := extractJsonMetadata(ctx, md, metadata.KeyIms, &scimUser.Ims); err != nil { + logging.OnError(err).Warn("Could not deserialize scim ims metadata") + } + + if err := extractJsonMetadata(ctx, md, metadata.KeyAddresses, &scimUser.Addresses); err != nil { + logging.OnError(err).Warn("Could not deserialize scim addresses metadata") + } + + if err := extractJsonMetadata(ctx, md, metadata.KeyPhotos, &scimUser.Photos); err != nil { + logging.OnError(err).Warn("Could not deserialize scim photos metadata") + } + + if err := extractJsonMetadata(ctx, md, metadata.KeyEntitlements, &scimUser.Entitlements); err != nil { + logging.OnError(err).Warn("Could not deserialize scim entitlements metadata") + } + + if err := extractJsonMetadata(ctx, md, metadata.KeyRoles, &scimUser.Roles); err != nil { + logging.OnError(err).Warn("Could not deserialize scim roles metadata") + } + + if user.Human != nil { + mapHumanToScimUser(ctx, user.Human, scimUser, md) + } + + return scimUser +} + +func mapHumanToScimUser(ctx context.Context, human *query.Human, user *ScimUser, md map[metadata.ScopedKey][]byte) { + user.DisplayName = human.DisplayName + user.NickName = human.NickName + user.PreferredLanguage = human.PreferredLanguage + user.Name = &ScimUserName{ + Formatted: human.DisplayName, + FamilyName: human.LastName, + GivenName: human.FirstName, + MiddleName: extractScalarMetadata(ctx, md, metadata.KeyMiddleName), + HonorificPrefix: extractScalarMetadata(ctx, md, metadata.KeyHonorificPrefix), + HonorificSuffix: extractScalarMetadata(ctx, md, metadata.KeyHonorificSuffix), + } + + if string(human.Email) != "" { + user.Emails = []*ScimEmail{ + { + Value: string(human.Email), + Primary: true, + }, + } + } + + if string(human.Phone) != "" { + user.PhoneNumbers = []*ScimPhoneNumber{ + { + Value: string(human.Phone), + Primary: true, + }, + } + } +} + +func (h *UsersHandler) buildResourceForQuery(ctx context.Context, user *query.User) *Resource { + return &Resource{ + Schemas: []schemas.ScimSchemaType{schemas.IdUser}, + Meta: &ResourceMeta{ + ResourceType: schemas.UserResourceType, + Created: user.CreationDate.UTC(), + LastModified: user.ChangeDate.UTC(), + Version: strconv.FormatUint(user.Sequence, 10), + Location: buildLocation(ctx, h, user.ID), + }, + } +} + func cascadingMemberships(memberships []*query.Membership) []*command.CascadingMembership { cascades := make([]*command.CascadingMembership, len(memberships)) for i, membership := range memberships { diff --git a/internal/api/scim/resources/user_metadata.go b/internal/api/scim/resources/user_metadata.go index 3d745d6857..f094695a27 100644 --- a/internal/api/scim/resources/user_metadata.go +++ b/internal/api/scim/resources/user_metadata.go @@ -12,9 +12,49 @@ import ( "github.com/zitadel/zitadel/internal/api/scim/schemas" "github.com/zitadel/zitadel/internal/api/scim/serrors" "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/zerrors" ) +func (h *UsersHandler) queryMetadataForUser(ctx context.Context, id string) (map[metadata.ScopedKey][]byte, error) { + queries := h.buildMetadataQueries(ctx) + + md, err := h.query.SearchUserMetadata(ctx, false, id, queries, false) + if err != nil { + return nil, err + } + + metadataMap := make(map[metadata.ScopedKey][]byte, len(md.Metadata)) + for _, entry := range md.Metadata { + metadataMap[metadata.ScopedKey(entry.Key)] = entry.Value + } + + return metadataMap, nil +} + +func (h *UsersHandler) buildMetadataQueries(ctx context.Context) *query.UserMetadataSearchQueries { + keyQueries := make([]query.SearchQuery, len(metadata.ScimUserRelevantMetadataKeys)) + for i, key := range metadata.ScimUserRelevantMetadataKeys { + keyQueries[i] = buildMetadataKeyQuery(ctx, key) + } + + queries := &query.UserMetadataSearchQueries{ + SearchRequest: query.SearchRequest{}, + Queries: []query.SearchQuery{query.Or(keyQueries...)}, + } + return queries +} + +func buildMetadataKeyQuery(ctx context.Context, key metadata.Key) query.SearchQuery { + scopedKey := metadata.ScopeKey(ctx, key) + q, err := query.NewUserMetadataKeySearchQuery(string(scopedKey), query.TextEquals) + if err != nil { + logging.Panic("Error build user metadata query for key " + key) + } + + return q +} + func (h *UsersHandler) mapMetadataToCommands(ctx context.Context, user *ScimUser) ([]*command.AddMetadataEntry, error) { md := make([]*command.AddMetadataEntry, 0, len(metadata.ScimUserRelevantMetadataKeys)) for _, key := range metadata.ScimUserRelevantMetadataKeys { @@ -51,7 +91,17 @@ func getValueForMetadataKey(user *ScimUser, key metadata.Key) ([]byte, error) { case metadata.KeyAddresses: fallthrough case metadata.KeyRoles: - return json.Marshal(value) + val, err := json.Marshal(value) + if err != nil { + return nil, err + } + + // null is considered no value + if len(val) == 4 && string(val) == "null" { + return nil, nil + } + + return val, nil // http url values case metadata.KeyProfileUrl: @@ -148,3 +198,36 @@ func getRawValueForMetadataKey(user *ScimUser, key metadata.Key) interface{} { logging.Panicf("Unknown or unsupported metadata key %s", key) return nil } + +func extractScalarMetadata(ctx context.Context, md map[metadata.ScopedKey][]byte, key metadata.Key) string { + val, ok := md[metadata.ScopeKey(ctx, key)] + if !ok { + return "" + } + + return string(val) +} + +func extractHttpURLMetadata(ctx context.Context, md map[metadata.ScopedKey][]byte, key metadata.Key) *schemas.HttpURL { + val, ok := md[metadata.ScopeKey(ctx, key)] + if !ok { + return nil + } + + url, err := schemas.ParseHTTPURL(string(val)) + if err != nil { + logging.OnError(err).Warn("Failed to parse scim url metadata for " + key) + return nil + } + + return url +} + +func extractJsonMetadata(ctx context.Context, md map[metadata.ScopedKey][]byte, key metadata.Key, v interface{}) error { + val, ok := md[metadata.ScopeKey(ctx, key)] + if !ok { + return nil + } + + return json.Unmarshal(val, v) +} diff --git a/internal/api/scim/server.go b/internal/api/scim/server.go index a2f9c7e7bf..2a4385e21e 100644 --- a/internal/api/scim/server.go +++ b/internal/api/scim/server.go @@ -54,6 +54,7 @@ func mapResource[T sresources.ResourceHolder](router *mux.Router, mw zhttp_middl resourceRouter := router.PathPrefix("/" + path.Join(zhttp.OrgIdInPathVariable, string(handler.ResourceNamePlural()))).Subrouter() resourceRouter.Handle("", mw(handleResourceCreatedResponse(adapter.Create))).Methods(http.MethodPost) + resourceRouter.Handle("/{id}", mw(handleResourceResponse(adapter.Get))).Methods(http.MethodGet) resourceRouter.Handle("/{id}", mw(handleEmptyResponse(adapter.Delete))).Methods(http.MethodDelete) } @@ -74,6 +75,22 @@ func handleResourceCreatedResponse[T sresources.ResourceHolder](next func(*http. } } +func handleResourceResponse[T sresources.ResourceHolder](next func(*http.Request) (T, error)) zhttp_middlware.HandlerFuncWithError { + return func(w http.ResponseWriter, r *http.Request) error { + entity, err := next(r) + if err != nil { + return err + } + + resource := entity.GetResource() + w.Header().Set(zhttp.ContentLocation, resource.Meta.Location) + + err = json.NewEncoder(w).Encode(entity) + logging.OnError(err).Warn("scim json response encoding failed") + return nil + } +} + func handleEmptyResponse(next func(*http.Request) error) zhttp_middlware.HandlerFuncWithError { return func(w http.ResponseWriter, r *http.Request) error { err := next(r) diff --git a/internal/integration/assert.go b/internal/integration/assert.go index 3f5ebdf54f..40c6815190 100644 --- a/internal/integration/assert.go +++ b/internal/integration/assert.go @@ -1,6 +1,7 @@ package integration import ( + "reflect" "testing" "time" @@ -175,3 +176,89 @@ func AssertMapContains[M ~map[K]V, K comparable, V any](t *testing.T, m M, key K assert.True(t, exists, "Key '%s' should exist in the map", key) assert.Equal(t, expectedValue, val, "Key '%s' should have value '%d'", key, expectedValue) } + +// PartiallyDeepEqual is similar to reflect.DeepEqual, +// but only compares exported non-zero fields of the expectedValue +func PartiallyDeepEqual(expected, actual interface{}) bool { + if expected == nil { + return actual == nil + } + + if actual == nil { + return false + } + + return partiallyDeepEqual(reflect.ValueOf(expected), reflect.ValueOf(actual)) +} + +func partiallyDeepEqual(expected, actual reflect.Value) bool { + // Dereference pointers if needed + if expected.Kind() == reflect.Ptr { + if expected.IsNil() { + return actual.IsNil() + } + + expected = expected.Elem() + } + + if actual.Kind() == reflect.Ptr { + if actual.IsNil() { + return false + } + + actual = actual.Elem() + } + + if expected.Type() != actual.Type() { + return false + } + + switch expected.Kind() { //nolint:exhaustive + case reflect.Struct: + for i := 0; i < expected.NumField(); i++ { + field := expected.Type().Field(i) + if field.PkgPath != "" { // Skip unexported fields + continue + } + + expectedField := expected.Field(i) + actualField := actual.Field(i) + + // Skip zero-value fields in expected + if reflect.DeepEqual(expectedField.Interface(), reflect.Zero(expectedField.Type()).Interface()) { + continue + } + + // Compare fields recursively + if !partiallyDeepEqual(expectedField, actualField) { + return false + } + } + return true + + case reflect.Slice, reflect.Array: + if expected.Len() > actual.Len() { + return false + } + + for i := 0; i < expected.Len(); i++ { + if !partiallyDeepEqual(expected.Index(i), actual.Index(i)) { + return false + } + } + + return true + + default: + // Compare primitive types + return reflect.DeepEqual(expected.Interface(), actual.Interface()) + } +} + +func Must[T any](result T, error error) T { + if error != nil { + panic(error) + } + + return result +} diff --git a/internal/integration/assert_test.go b/internal/integration/assert_test.go index 0355ffec98..191078ffd1 100644 --- a/internal/integration/assert_test.go +++ b/internal/integration/assert_test.go @@ -50,3 +50,153 @@ func TestAssertDetails(t *testing.T) { }) } } + +func TestPartiallyDeepEqual(t *testing.T) { + type SecondaryNestedType struct { + Value int + } + type NestedType struct { + Value int + ValueSlice []int + Nested SecondaryNestedType + NestedPointer *SecondaryNestedType + } + + type args struct { + expected interface{} + actual interface{} + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "nil", + args: args{ + expected: nil, + actual: nil, + }, + want: true, + }, + { + name: "scalar value", + args: args{ + expected: 10, + actual: 10, + }, + want: true, + }, + { + name: "different scalar value", + args: args{ + expected: 11, + actual: 10, + }, + want: false, + }, + { + name: "string value", + args: args{ + expected: "foo", + actual: "foo", + }, + want: true, + }, + { + name: "different string value", + args: args{ + expected: "foo2", + actual: "foo", + }, + want: false, + }, + { + name: "scalar only set in actual", + args: args{ + expected: &SecondaryNestedType{}, + actual: &SecondaryNestedType{Value: 10}, + }, + want: true, + }, + { + name: "scalar equal", + args: args{ + expected: &SecondaryNestedType{Value: 10}, + actual: &SecondaryNestedType{Value: 10}, + }, + want: true, + }, + { + name: "scalar only set in expected", + args: args{ + expected: &SecondaryNestedType{Value: 10}, + actual: &SecondaryNestedType{}, + }, + want: false, + }, + { + name: "ptr only set in expected", + args: args{ + expected: &NestedType{NestedPointer: &SecondaryNestedType{Value: 10}}, + actual: &NestedType{}, + }, + want: false, + }, + { + name: "ptr only set in actual", + args: args{ + expected: &NestedType{}, + actual: &NestedType{NestedPointer: &SecondaryNestedType{Value: 10}}, + }, + want: true, + }, + { + name: "ptr equal", + args: args{ + expected: &NestedType{NestedPointer: &SecondaryNestedType{Value: 10}}, + actual: &NestedType{NestedPointer: &SecondaryNestedType{Value: 10}}, + }, + want: true, + }, + { + name: "nested equal", + args: args{ + expected: &NestedType{Nested: SecondaryNestedType{Value: 10}}, + actual: &NestedType{Nested: SecondaryNestedType{Value: 10}}, + }, + want: true, + }, + { + name: "slice equal", + args: args{ + expected: &NestedType{ValueSlice: []int{10, 20}}, + actual: &NestedType{ValueSlice: []int{10, 20}}, + }, + want: true, + }, + { + name: "slice additional in expected", + args: args{ + expected: &NestedType{ValueSlice: []int{10, 20, 30}}, + actual: &NestedType{ValueSlice: []int{10, 20}}, + }, + want: false, + }, + { + name: "slice additional in actual", + args: args{ + expected: &NestedType{ValueSlice: []int{10, 20}}, + actual: &NestedType{ValueSlice: []int{10, 20, 30}}, + }, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := PartiallyDeepEqual(tt.args.expected, tt.args.actual); got != tt.want { + t.Errorf("PartiallyDeepEqual() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/integration/scim/client.go b/internal/integration/scim/client.go index 478c831826..2a6f106b9e 100644 --- a/internal/integration/scim/client.go +++ b/internal/integration/scim/client.go @@ -18,10 +18,10 @@ import ( ) type Client struct { - Users *ResourceClient + Users *ResourceClient[resources.ScimUser] } -type ResourceClient struct { +type ResourceClient[T any] struct { client *http.Client baseUrl string resourceName string @@ -44,7 +44,7 @@ func NewScimClient(target string) *Client { target = "http://" + target + schemas.HandlerPrefix client := &http.Client{} return &Client{ - Users: &ResourceClient{ + Users: &ResourceClient[resources.ScimUser]{ client: client, baseUrl: target, resourceName: "Users", @@ -52,17 +52,19 @@ func NewScimClient(target string) *Client { } } -func (c *ResourceClient) Create(ctx context.Context, orgID string, body []byte) (*resources.ScimUser, error) { - user := new(resources.ScimUser) - err := c.doWithBody(ctx, http.MethodPost, orgID, "", bytes.NewReader(body), user) - return user, err +func (c *ResourceClient[T]) Create(ctx context.Context, orgID string, body []byte) (*T, error) { + return c.doWithBody(ctx, http.MethodPost, orgID, "", bytes.NewReader(body)) } -func (c *ResourceClient) Delete(ctx context.Context, orgID, id string) error { +func (c *ResourceClient[T]) Get(ctx context.Context, orgID, resourceID string) (*T, error) { + return c.doWithBody(ctx, http.MethodGet, orgID, resourceID, nil) +} + +func (c *ResourceClient[T]) Delete(ctx context.Context, orgID, id string) error { return c.do(ctx, http.MethodDelete, orgID, id) } -func (c *ResourceClient) do(ctx context.Context, method, orgID, url string) error { +func (c *ResourceClient[T]) do(ctx context.Context, method, orgID, url string) error { req, err := http.NewRequestWithContext(ctx, method, c.buildURL(orgID, url), nil) if err != nil { return err @@ -71,17 +73,18 @@ func (c *ResourceClient) do(ctx context.Context, method, orgID, url string) erro return c.doReq(req, nil) } -func (c *ResourceClient) doWithBody(ctx context.Context, method, orgID, url string, body io.Reader, responseEntity interface{}) error { +func (c *ResourceClient[T]) doWithBody(ctx context.Context, method, orgID, url string, body io.Reader) (*T, error) { req, err := http.NewRequestWithContext(ctx, method, c.buildURL(orgID, url), body) if err != nil { - return err + return nil, err } req.Header.Set(zhttp.ContentType, middleware.ContentTypeScim) - return c.doReq(req, responseEntity) + responseEntity := new(T) + return responseEntity, c.doReq(req, responseEntity) } -func (c *ResourceClient) doReq(req *http.Request, responseEntity interface{}) error { +func (c *ResourceClient[T]) doReq(req *http.Request, responseEntity *T) error { addTokenAsHeader(req) resp, err := c.client.Do(req) @@ -133,7 +136,7 @@ func readScimError(resp *http.Response) error { return scimErr } -func (c *ResourceClient) buildURL(orgID, segment string) string { +func (c *ResourceClient[T]) buildURL(orgID, segment string) string { if segment == "" { return c.baseUrl + "/" + path.Join(orgID, c.resourceName) } From e2a2e13d44e9c86ebbc65153e0a40cbbae873df8 Mon Sep 17 00:00:00 2001 From: Stefan Benz <46600784+stebenz@users.noreply.github.com> Date: Tue, 14 Jan 2025 07:49:26 +0100 Subject: [PATCH 16/30] docs: correct usage of nbf in oidc id token (#9173) # Which Problems Are Solved Wrongly not returned but documented 'nbf' claim in ID token. # How the Problems Are Solved Correct documentation to not include 'nbf' in ID token. # Additional Changes None # Additional Context None --- docs/docs/apis/openidoauth/claims.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/apis/openidoauth/claims.md b/docs/docs/apis/openidoauth/claims.md index e82f0b4059..4129806aef 100644 --- a/docs/docs/apis/openidoauth/claims.md +++ b/docs/docs/apis/openidoauth/claims.md @@ -26,7 +26,7 @@ Please check below the matrix for an overview where which scope is asserted. | jti | No | Yes | No | When JWT | | locale | When requested | When requested | When requested and response_type `id_token` | No | | name | When requested | When requested | When requested and response_type `id_token` | No | -| nbf | No | Yes | Yes | When JWT | +| nbf | No | Yes | No | When JWT | | nonce | No | No | When provided in the authorization request [^1] | No | | phone | When requested | When requested | When requested and response_type `id_token` | No | | phone_verified | When requested | When requested | When requested and response_type `id_token` | No | From 84997ffe1aaaebe8ed97edd689242f7d3fca6fd3 Mon Sep 17 00:00:00 2001 From: Stefan Benz <46600784+stebenz@users.noreply.github.com> Date: Tue, 14 Jan 2025 14:15:59 +0100 Subject: [PATCH 17/30] fix(session v2): allow searching for own sessions or user agent (fingerprintID) (#9110) # Which Problems Are Solved ListSessions only works to list the sessions that you are the creator of. # How the Problems Are Solved Add options to search for sessions created by other users, sessions belonging to the same useragent and sessions belonging to your user. Possible through additional search parameters which as default use the information contained in your session token but can also be filled with specific IDs. # Additional Changes Remodel integration tests, to separate the Create and Get of sessions correctly. # Additional Context Closes #8301 --------- Co-authored-by: Livio Spring --- cmd/start/start.go | 4 +- internal/api/authz/context_mock.go | 5 + .../session/v2/integration_test/query_test.go | 714 ++++++++++++++++++ .../v2/integration_test/server_test.go | 74 ++ .../v2/integration_test/session_test.go | 195 ++--- internal/api/grpc/session/v2/query.go | 262 +++++++ internal/api/grpc/session/v2/server.go | 9 +- internal/api/grpc/session/v2/session.go | 230 ------ internal/api/grpc/session/v2/session_test.go | 183 ++++- .../v2beta/integration_test/query_test.go | 512 +++++++++++++ .../v2beta/integration_test/server_test.go | 74 ++ .../v2beta/integration_test/session_test.go | 193 ++--- internal/api/grpc/session/v2beta/server.go | 9 +- internal/api/grpc/session/v2beta/session.go | 4 +- .../eventstore/token_verifier.go | 2 +- .../handlers/mock/commands.mock.go | 145 ++-- .../handlers/mock/queries.mock.go | 129 ++-- internal/notification/handlers/queries.go | 2 +- .../notification/handlers/user_notifier.go | 4 +- .../handlers/user_notifier_legacy.go | 4 +- .../handlers/user_notifier_legacy_test.go | 14 +- .../handlers/user_notifier_test.go | 12 +- internal/query/session.go | 97 ++- internal/query/sessions_test.go | 193 +++++ proto/zitadel/session/v2/session.proto | 30 +- 25 files changed, 2398 insertions(+), 702 deletions(-) create mode 100644 internal/api/grpc/session/v2/integration_test/query_test.go create mode 100644 internal/api/grpc/session/v2/integration_test/server_test.go create mode 100644 internal/api/grpc/session/v2/query.go create mode 100644 internal/api/grpc/session/v2beta/integration_test/query_test.go create mode 100644 internal/api/grpc/session/v2beta/integration_test/server_test.go diff --git a/cmd/start/start.go b/cmd/start/start.go index 21f445cfd6..db9c9afc54 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -442,7 +442,7 @@ func startAPIs( if err := apis.RegisterService(ctx, user_v2.CreateServer(commands, queries, keys.User, keys.IDPConfig, idp.CallbackURL(), idp.SAMLRootURL(), assets.AssetAPI(), permissionCheck)); err != nil { return nil, err } - if err := apis.RegisterService(ctx, session_v2beta.CreateServer(commands, queries)); err != nil { + if err := apis.RegisterService(ctx, session_v2beta.CreateServer(commands, queries, permissionCheck)); err != nil { return nil, err } if err := apis.RegisterService(ctx, settings_v2beta.CreateServer(commands, queries)); err != nil { @@ -454,7 +454,7 @@ func startAPIs( if err := apis.RegisterService(ctx, feature_v2beta.CreateServer(commands, queries)); err != nil { return nil, err } - if err := apis.RegisterService(ctx, session_v2.CreateServer(commands, queries)); err != nil { + if err := apis.RegisterService(ctx, session_v2.CreateServer(commands, queries, permissionCheck)); err != nil { return nil, err } if err := apis.RegisterService(ctx, settings_v2.CreateServer(commands, queries)); err != nil { diff --git a/internal/api/authz/context_mock.go b/internal/api/authz/context_mock.go index 6badf15862..6891030bd3 100644 --- a/internal/api/authz/context_mock.go +++ b/internal/api/authz/context_mock.go @@ -7,6 +7,11 @@ func NewMockContext(instanceID, orgID, userID string) context.Context { return context.WithValue(ctx, instanceKey, &instance{id: instanceID}) } +func NewMockContextWithAgent(instanceID, orgID, userID, agentID string) context.Context { + ctx := context.WithValue(context.Background(), dataKey, CtxData{UserID: userID, OrgID: orgID, AgentID: agentID}) + return context.WithValue(ctx, instanceKey, &instance{id: instanceID}) +} + func NewMockContextWithPermissions(instanceID, orgID, userID string, permissions []string) context.Context { ctx := context.WithValue(context.Background(), dataKey, CtxData{UserID: userID, OrgID: orgID}) ctx = context.WithValue(ctx, instanceKey, &instance{id: instanceID}) diff --git a/internal/api/grpc/session/v2/integration_test/query_test.go b/internal/api/grpc/session/v2/integration_test/query_test.go new file mode 100644 index 0000000000..36e412be23 --- /dev/null +++ b/internal/api/grpc/session/v2/integration_test/query_test.go @@ -0,0 +1,714 @@ +//go:build integration + +package session_test + +import ( + "context" + "testing" + "time" + + "github.com/golang/protobuf/ptypes/timestamp" + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/durationpb" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/pkg/grpc/object/v2" + "github.com/zitadel/zitadel/pkg/grpc/session/v2" +) + +func TestServer_GetSession(t *testing.T) { + type args struct { + ctx context.Context + req *session.GetSessionRequest + dep func(ctx context.Context, t *testing.T, request *session.GetSessionRequest) uint64 + } + tests := []struct { + name string + args args + want *session.GetSessionResponse + wantFactors []wantFactor + wantExpirationWindow time.Duration + wantErr bool + }{ + { + name: "get session, no id provided", + args: args{ + CTX, + &session.GetSessionRequest{ + SessionId: "", + }, + nil, + }, + wantErr: true, + }, + { + name: "get session, not found", + args: args{ + CTX, + &session.GetSessionRequest{ + SessionId: "unknown", + }, + nil, + }, + wantErr: true, + }, + { + name: "get session, no permission", + args: args{ + UserCTX, + &session.GetSessionRequest{}, + func(ctx context.Context, t *testing.T, request *session.GetSessionRequest) uint64 { + resp, err := Client.CreateSession(ctx, &session.CreateSessionRequest{}) + require.NoError(t, err) + request.SessionId = resp.SessionId + return resp.GetDetails().GetSequence() + }, + }, + wantErr: true, + }, + { + name: "get session, permission, ok", + args: args{ + CTX, + &session.GetSessionRequest{}, + func(ctx context.Context, t *testing.T, request *session.GetSessionRequest) uint64 { + resp, err := Client.CreateSession(ctx, &session.CreateSessionRequest{}) + require.NoError(t, err) + request.SessionId = resp.SessionId + return resp.GetDetails().GetSequence() + }, + }, + want: &session.GetSessionResponse{ + Session: &session.Session{}, + }, + }, + { + name: "get session, token, ok", + args: args{ + UserCTX, + &session.GetSessionRequest{}, + func(ctx context.Context, t *testing.T, request *session.GetSessionRequest) uint64 { + resp, err := Client.CreateSession(ctx, &session.CreateSessionRequest{}) + require.NoError(t, err) + request.SessionId = resp.SessionId + request.SessionToken = gu.Ptr(resp.SessionToken) + return resp.GetDetails().GetSequence() + }, + }, + want: &session.GetSessionResponse{ + Session: &session.Session{}, + }, + }, + { + name: "get session, user agent, ok", + args: args{ + UserCTX, + &session.GetSessionRequest{}, + func(ctx context.Context, t *testing.T, request *session.GetSessionRequest) uint64 { + resp, err := Client.CreateSession(ctx, &session.CreateSessionRequest{ + UserAgent: &session.UserAgent{ + FingerprintId: gu.Ptr("fingerPrintID"), + Ip: gu.Ptr("1.2.3.4"), + Description: gu.Ptr("Description"), + Header: map[string]*session.UserAgent_HeaderValues{ + "foo": {Values: []string{"foo", "bar"}}, + }, + }, + }, + ) + require.NoError(t, err) + request.SessionId = resp.SessionId + request.SessionToken = gu.Ptr(resp.SessionToken) + return resp.GetDetails().GetSequence() + }, + }, + want: &session.GetSessionResponse{ + Session: &session.Session{ + UserAgent: &session.UserAgent{ + FingerprintId: gu.Ptr("fingerPrintID"), + Ip: gu.Ptr("1.2.3.4"), + Description: gu.Ptr("Description"), + Header: map[string]*session.UserAgent_HeaderValues{ + "foo": {Values: []string{"foo", "bar"}}, + }, + }, + }, + }, + }, + { + name: "get session, lifetime, ok", + args: args{ + UserCTX, + &session.GetSessionRequest{}, + func(ctx context.Context, t *testing.T, request *session.GetSessionRequest) uint64 { + resp, err := Client.CreateSession(ctx, &session.CreateSessionRequest{ + Lifetime: durationpb.New(5 * time.Minute), + }, + ) + require.NoError(t, err) + request.SessionId = resp.SessionId + request.SessionToken = gu.Ptr(resp.SessionToken) + return resp.GetDetails().GetSequence() + }, + }, + wantExpirationWindow: 5 * time.Minute, + want: &session.GetSessionResponse{ + Session: &session.Session{}, + }, + }, + { + name: "get session, metadata, ok", + args: args{ + UserCTX, + &session.GetSessionRequest{}, + func(ctx context.Context, t *testing.T, request *session.GetSessionRequest) uint64 { + resp, err := Client.CreateSession(ctx, &session.CreateSessionRequest{ + Metadata: map[string][]byte{"foo": []byte("bar")}, + }, + ) + require.NoError(t, err) + request.SessionId = resp.SessionId + request.SessionToken = gu.Ptr(resp.SessionToken) + return resp.GetDetails().GetSequence() + }, + }, + want: &session.GetSessionResponse{ + Session: &session.Session{ + Metadata: map[string][]byte{"foo": []byte("bar")}, + }, + }, + }, + { + name: "get session, user, ok", + args: args{ + UserCTX, + &session.GetSessionRequest{}, + func(ctx context.Context, t *testing.T, request *session.GetSessionRequest) uint64 { + resp, err := Client.CreateSession(ctx, &session.CreateSessionRequest{ + Checks: &session.Checks{ + User: &session.CheckUser{ + Search: &session.CheckUser_UserId{ + UserId: User.GetUserId(), + }, + }, + }, + }, + ) + require.NoError(t, err) + request.SessionId = resp.SessionId + request.SessionToken = gu.Ptr(resp.SessionToken) + return resp.GetDetails().GetSequence() + }, + }, + wantFactors: []wantFactor{wantUserFactor}, + want: &session.GetSessionResponse{ + Session: &session.Session{}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var sequence uint64 + if tt.args.dep != nil { + sequence = tt.args.dep(CTX, t, tt.args.req) + } + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tt.args.ctx, time.Minute) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + got, err := Client.GetSession(tt.args.ctx, tt.args.req) + if tt.wantErr { + assert.Error(ttt, err) + return + } + if !assert.NoError(ttt, err) { + return + } + + tt.want.Session.Id = tt.args.req.SessionId + tt.want.Session.Sequence = sequence + verifySession(ttt, got.GetSession(), tt.want.GetSession(), time.Minute, tt.wantExpirationWindow, User.GetUserId(), tt.wantFactors...) + }, retryDuration, tick) + }) + } +} + +type sessionAttr struct { + ID string + UserID string + UserAgent string + CreationDate *timestamp.Timestamp + ChangeDate *timestamppb.Timestamp + Details *object.Details +} + +type sessionAttrs []*sessionAttr + +func (u sessionAttrs) ids() []string { + ids := make([]string, len(u)) + for i := range u { + ids[i] = u[i].ID + } + return ids +} + +func createSessions(ctx context.Context, t *testing.T, count int, userID string, userAgent string, lifetime *durationpb.Duration, metadata map[string][]byte) sessionAttrs { + infos := make([]*sessionAttr, count) + for i := 0; i < count; i++ { + infos[i] = createSession(ctx, t, userID, userAgent, lifetime, metadata) + } + return infos +} + +func createSession(ctx context.Context, t *testing.T, userID string, userAgent string, lifetime *durationpb.Duration, metadata map[string][]byte) *sessionAttr { + req := &session.CreateSessionRequest{} + if userID != "" { + req.Checks = &session.Checks{ + User: &session.CheckUser{ + Search: &session.CheckUser_UserId{ + UserId: userID, + }, + }, + } + } + if userAgent != "" { + req.UserAgent = &session.UserAgent{ + FingerprintId: gu.Ptr(userAgent), + Ip: gu.Ptr("1.2.3.4"), + Description: gu.Ptr("Description"), + Header: map[string]*session.UserAgent_HeaderValues{ + "foo": {Values: []string{"foo", "bar"}}, + }, + } + } + if lifetime != nil { + req.Lifetime = lifetime + } + if metadata != nil { + req.Metadata = metadata + } + resp, err := Client.CreateSession(ctx, req) + require.NoError(t, err) + return &sessionAttr{ + resp.GetSessionId(), + userID, + userAgent, + resp.GetDetails().GetChangeDate(), + resp.GetDetails().GetChangeDate(), + resp.GetDetails(), + } +} + +func TestServer_ListSessions(t *testing.T) { + type args struct { + ctx context.Context + req *session.ListSessionsRequest + dep func(ctx context.Context, t *testing.T, request *session.ListSessionsRequest) []*sessionAttr + } + tests := []struct { + name string + args args + want *session.ListSessionsResponse + wantFactors []wantFactor + wantExpirationWindow time.Duration + wantErr bool + }{ + { + name: "list sessions, not found", + args: args{ + CTX, + &session.ListSessionsRequest{ + Queries: []*session.SearchQuery{ + {Query: &session.SearchQuery_IdsQuery{IdsQuery: &session.IDsQuery{Ids: []string{"unknown"}}}}, + }, + }, + func(ctx context.Context, t *testing.T, request *session.ListSessionsRequest) []*sessionAttr { + return []*sessionAttr{} + }, + }, + want: &session.ListSessionsResponse{ + Details: &object.ListDetails{ + TotalResult: 0, + Timestamp: timestamppb.Now(), + }, + Sessions: []*session.Session{}, + }, + }, + { + name: "list sessions, no permission", + args: args{ + UserCTX, + &session.ListSessionsRequest{ + Queries: []*session.SearchQuery{}, + }, + func(ctx context.Context, t *testing.T, request *session.ListSessionsRequest) []*sessionAttr { + info := createSession(ctx, t, "", "", nil, nil) + request.Queries = append(request.Queries, &session.SearchQuery{Query: &session.SearchQuery_IdsQuery{IdsQuery: &session.IDsQuery{Ids: []string{info.ID}}}}) + return []*sessionAttr{} + }, + }, + want: &session.ListSessionsResponse{ + Details: &object.ListDetails{ + TotalResult: 1, + Timestamp: timestamppb.Now(), + }, + Sessions: []*session.Session{}, + }, + }, + { + name: "list sessions, permission, ok", + args: args{ + CTX, + &session.ListSessionsRequest{}, + func(ctx context.Context, t *testing.T, request *session.ListSessionsRequest) []*sessionAttr { + info := createSession(ctx, t, "", "", nil, nil) + request.Queries = append(request.Queries, &session.SearchQuery{Query: &session.SearchQuery_IdsQuery{IdsQuery: &session.IDsQuery{Ids: []string{info.ID}}}}) + return []*sessionAttr{info} + }, + }, + want: &session.ListSessionsResponse{ + Details: &object.ListDetails{ + TotalResult: 1, + Timestamp: timestamppb.Now(), + }, + Sessions: []*session.Session{{}}, + }, + }, + { + name: "list sessions, full, ok", + args: args{ + CTX, + &session.ListSessionsRequest{}, + func(ctx context.Context, t *testing.T, request *session.ListSessionsRequest) []*sessionAttr { + info := createSession(ctx, t, User.GetUserId(), "agent", durationpb.New(time.Minute*5), map[string][]byte{"key": []byte("value")}) + request.Queries = append(request.Queries, &session.SearchQuery{Query: &session.SearchQuery_IdsQuery{IdsQuery: &session.IDsQuery{Ids: []string{info.ID}}}}) + return []*sessionAttr{info} + }, + }, + wantExpirationWindow: time.Minute * 5, + wantFactors: []wantFactor{wantUserFactor}, + want: &session.ListSessionsResponse{ + Details: &object.ListDetails{ + TotalResult: 1, + Timestamp: timestamppb.Now(), + }, + Sessions: []*session.Session{ + { + Metadata: map[string][]byte{"key": []byte("value")}, + UserAgent: &session.UserAgent{ + FingerprintId: gu.Ptr("agent"), + Ip: gu.Ptr("1.2.3.4"), + Description: gu.Ptr("Description"), + Header: map[string]*session.UserAgent_HeaderValues{ + "foo": {Values: []string{"foo", "bar"}}, + }, + }, + }, + }, + }, + }, + { + name: "list sessions, multiple, ok", + args: args{ + CTX, + &session.ListSessionsRequest{}, + func(ctx context.Context, t *testing.T, request *session.ListSessionsRequest) []*sessionAttr { + infos := createSessions(ctx, t, 3, User.GetUserId(), "agent", durationpb.New(time.Minute*5), map[string][]byte{"key": []byte("value")}) + request.Queries = append(request.Queries, &session.SearchQuery{Query: &session.SearchQuery_IdsQuery{IdsQuery: &session.IDsQuery{Ids: infos.ids()}}}) + return infos + }, + }, + wantExpirationWindow: time.Minute * 5, + wantFactors: []wantFactor{wantUserFactor}, + want: &session.ListSessionsResponse{ + Details: &object.ListDetails{ + TotalResult: 3, + Timestamp: timestamppb.Now(), + }, + Sessions: []*session.Session{ + { + Metadata: map[string][]byte{"key": []byte("value")}, + UserAgent: &session.UserAgent{ + FingerprintId: gu.Ptr("agent"), + Ip: gu.Ptr("1.2.3.4"), + Description: gu.Ptr("Description"), + Header: map[string]*session.UserAgent_HeaderValues{ + "foo": {Values: []string{"foo", "bar"}}, + }, + }, + }, + { + Metadata: map[string][]byte{"key": []byte("value")}, + UserAgent: &session.UserAgent{ + FingerprintId: gu.Ptr("agent"), + Ip: gu.Ptr("1.2.3.4"), + Description: gu.Ptr("Description"), + Header: map[string]*session.UserAgent_HeaderValues{ + "foo": {Values: []string{"foo", "bar"}}, + }, + }, + }, + { + Metadata: map[string][]byte{"key": []byte("value")}, + UserAgent: &session.UserAgent{ + FingerprintId: gu.Ptr("agent"), + Ip: gu.Ptr("1.2.3.4"), + Description: gu.Ptr("Description"), + Header: map[string]*session.UserAgent_HeaderValues{ + "foo": {Values: []string{"foo", "bar"}}, + }, + }, + }, + }, + }, + }, + { + name: "list sessions, userid, ok", + args: args{ + CTX, + &session.ListSessionsRequest{}, + func(ctx context.Context, t *testing.T, request *session.ListSessionsRequest) []*sessionAttr { + createdUser := createFullUser(ctx) + info := createSession(ctx, t, createdUser.GetUserId(), "agent", durationpb.New(time.Minute*5), map[string][]byte{"key": []byte("value")}) + request.Queries = append(request.Queries, &session.SearchQuery{Query: &session.SearchQuery_UserIdQuery{UserIdQuery: &session.UserIDQuery{Id: createdUser.GetUserId()}}}) + return []*sessionAttr{info} + }, + }, + wantExpirationWindow: time.Minute * 5, + wantFactors: []wantFactor{wantUserFactor}, + want: &session.ListSessionsResponse{ + Details: &object.ListDetails{ + TotalResult: 1, + Timestamp: timestamppb.Now(), + }, + Sessions: []*session.Session{ + { + Metadata: map[string][]byte{"key": []byte("value")}, + UserAgent: &session.UserAgent{ + FingerprintId: gu.Ptr("agent"), + Ip: gu.Ptr("1.2.3.4"), + Description: gu.Ptr("Description"), + Header: map[string]*session.UserAgent_HeaderValues{ + "foo": {Values: []string{"foo", "bar"}}, + }, + }, + }, + }, + }, + }, + { + name: "list sessions, own creator, ok", + args: args{ + CTX, + &session.ListSessionsRequest{}, + func(ctx context.Context, t *testing.T, request *session.ListSessionsRequest) []*sessionAttr { + info := createSession(ctx, t, User.GetUserId(), "agent", durationpb.New(time.Minute*5), map[string][]byte{"key": []byte("value")}) + request.Queries = append(request.Queries, + &session.SearchQuery{Query: &session.SearchQuery_IdsQuery{IdsQuery: &session.IDsQuery{Ids: []string{info.ID}}}}, + &session.SearchQuery{Query: &session.SearchQuery_CreatorQuery{CreatorQuery: &session.CreatorQuery{}}}) + return []*sessionAttr{info} + }, + }, + wantExpirationWindow: time.Minute * 5, + wantFactors: []wantFactor{wantUserFactor}, + want: &session.ListSessionsResponse{ + Details: &object.ListDetails{ + TotalResult: 1, + Timestamp: timestamppb.Now(), + }, + Sessions: []*session.Session{ + { + Metadata: map[string][]byte{"key": []byte("value")}, + UserAgent: &session.UserAgent{ + FingerprintId: gu.Ptr("agent"), + Ip: gu.Ptr("1.2.3.4"), + Description: gu.Ptr("Description"), + Header: map[string]*session.UserAgent_HeaderValues{ + "foo": {Values: []string{"foo", "bar"}}, + }, + }, + }, + }, + }, + }, + { + name: "list sessions, creator, ok", + args: args{ + IAMOwnerCTX, + &session.ListSessionsRequest{}, + func(ctx context.Context, t *testing.T, request *session.ListSessionsRequest) []*sessionAttr { + info := createSession(ctx, t, User.GetUserId(), "agent", durationpb.New(time.Minute*5), map[string][]byte{"key": []byte("value")}) + request.Queries = append(request.Queries, + &session.SearchQuery{Query: &session.SearchQuery_IdsQuery{IdsQuery: &session.IDsQuery{Ids: []string{info.ID}}}}, + &session.SearchQuery{Query: &session.SearchQuery_CreatorQuery{CreatorQuery: &session.CreatorQuery{Id: gu.Ptr(Instance.Users.Get(integration.UserTypeOrgOwner).ID)}}}) + return []*sessionAttr{info} + }, + }, + wantExpirationWindow: time.Minute * 5, + wantFactors: []wantFactor{wantUserFactor}, + want: &session.ListSessionsResponse{ + Details: &object.ListDetails{ + TotalResult: 1, + Timestamp: timestamppb.Now(), + }, + Sessions: []*session.Session{ + { + Metadata: map[string][]byte{"key": []byte("value")}, + UserAgent: &session.UserAgent{ + FingerprintId: gu.Ptr("agent"), + Ip: gu.Ptr("1.2.3.4"), + Description: gu.Ptr("Description"), + Header: map[string]*session.UserAgent_HeaderValues{ + "foo": {Values: []string{"foo", "bar"}}, + }, + }, + }, + }, + }, + }, + { + name: "list sessions, wrong creator", + args: args{ + IAMOwnerCTX, + &session.ListSessionsRequest{}, + func(ctx context.Context, t *testing.T, request *session.ListSessionsRequest) []*sessionAttr { + info := createSession(ctx, t, User.GetUserId(), "agent", durationpb.New(time.Minute*5), map[string][]byte{"key": []byte("value")}) + request.Queries = append(request.Queries, + &session.SearchQuery{Query: &session.SearchQuery_IdsQuery{IdsQuery: &session.IDsQuery{Ids: []string{info.ID}}}}, + &session.SearchQuery{Query: &session.SearchQuery_CreatorQuery{CreatorQuery: &session.CreatorQuery{}}}) + return []*sessionAttr{} + }, + }, + wantExpirationWindow: time.Minute * 5, + wantFactors: []wantFactor{wantUserFactor}, + want: &session.ListSessionsResponse{ + Details: &object.ListDetails{ + TotalResult: 0, + Timestamp: timestamppb.Now(), + }, + Sessions: []*session.Session{}, + }, + }, + { + name: "list sessions, empty creator", + args: args{ + IAMOwnerCTX, + &session.ListSessionsRequest{}, + func(ctx context.Context, t *testing.T, request *session.ListSessionsRequest) []*sessionAttr { + request.Queries = append(request.Queries, + &session.SearchQuery{Query: &session.SearchQuery_CreatorQuery{CreatorQuery: &session.CreatorQuery{Id: gu.Ptr("")}}}) + return []*sessionAttr{} + }, + }, + wantExpirationWindow: time.Minute * 5, + wantFactors: []wantFactor{wantUserFactor}, + wantErr: true, + }, + { + name: "list sessions, useragent, ok", + args: args{ + IAMOwnerCTX, + &session.ListSessionsRequest{}, + func(ctx context.Context, t *testing.T, request *session.ListSessionsRequest) []*sessionAttr { + info := createSession(ctx, t, User.GetUserId(), "useragent", durationpb.New(time.Minute*5), map[string][]byte{"key": []byte("value")}) + request.Queries = append(request.Queries, + &session.SearchQuery{Query: &session.SearchQuery_IdsQuery{IdsQuery: &session.IDsQuery{Ids: []string{info.ID}}}}, + &session.SearchQuery{Query: &session.SearchQuery_UserAgentQuery{UserAgentQuery: &session.UserAgentQuery{FingerprintId: gu.Ptr("useragent")}}}) + return []*sessionAttr{info} + }, + }, + wantExpirationWindow: time.Minute * 5, + wantFactors: []wantFactor{wantUserFactor}, + want: &session.ListSessionsResponse{ + Details: &object.ListDetails{ + TotalResult: 1, + Timestamp: timestamppb.Now(), + }, + Sessions: []*session.Session{ + { + Metadata: map[string][]byte{"key": []byte("value")}, + UserAgent: &session.UserAgent{ + FingerprintId: gu.Ptr("useragent"), + Ip: gu.Ptr("1.2.3.4"), + Description: gu.Ptr("Description"), + Header: map[string]*session.UserAgent_HeaderValues{ + "foo": {Values: []string{"foo", "bar"}}, + }, + }, + }, + }, + }, + }, + { + name: "list sessions, wrong useragent", + args: args{ + IAMOwnerCTX, + &session.ListSessionsRequest{}, + func(ctx context.Context, t *testing.T, request *session.ListSessionsRequest) []*sessionAttr { + info := createSession(ctx, t, User.GetUserId(), "useragent", durationpb.New(time.Minute*5), map[string][]byte{"key": []byte("value")}) + request.Queries = append(request.Queries, + &session.SearchQuery{Query: &session.SearchQuery_IdsQuery{IdsQuery: &session.IDsQuery{Ids: []string{info.ID}}}}, + &session.SearchQuery{Query: &session.SearchQuery_UserAgentQuery{UserAgentQuery: &session.UserAgentQuery{FingerprintId: gu.Ptr("wronguseragent")}}}) + return []*sessionAttr{} + }, + }, + wantExpirationWindow: time.Minute * 5, + wantFactors: []wantFactor{wantUserFactor}, + want: &session.ListSessionsResponse{ + Details: &object.ListDetails{ + TotalResult: 0, + Timestamp: timestamppb.Now(), + }, + Sessions: []*session.Session{}, + }, + }, + { + name: "list sessions, empty useragent", + args: args{ + IAMOwnerCTX, + &session.ListSessionsRequest{}, + func(ctx context.Context, t *testing.T, request *session.ListSessionsRequest) []*sessionAttr { + request.Queries = append(request.Queries, + &session.SearchQuery{Query: &session.SearchQuery_UserAgentQuery{UserAgentQuery: &session.UserAgentQuery{FingerprintId: gu.Ptr("")}}}) + return []*sessionAttr{} + }, + }, + wantExpirationWindow: time.Minute * 5, + wantFactors: []wantFactor{wantUserFactor}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + infos := tt.args.dep(CTX, t, tt.args.req) + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tt.args.ctx, time.Minute) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + got, err := Client.ListSessions(tt.args.ctx, tt.args.req) + if tt.wantErr { + assert.Error(ttt, err) + return + } + if !assert.NoError(ttt, err) { + return + } + + if !assert.Equal(ttt, got.Details.TotalResult, tt.want.Details.TotalResult) || !assert.Len(ttt, got.Sessions, len(tt.want.Sessions)) { + return + } + + for i := range infos { + tt.want.Sessions[i].Id = infos[i].ID + tt.want.Sessions[i].Sequence = infos[i].Details.GetSequence() + tt.want.Sessions[i].CreationDate = infos[i].Details.GetChangeDate() + tt.want.Sessions[i].ChangeDate = infos[i].Details.GetChangeDate() + + verifySession(ttt, got.Sessions[i], tt.want.Sessions[i], time.Minute, tt.wantExpirationWindow, infos[i].UserID, tt.wantFactors...) + } + integration.AssertListDetails(ttt, tt.want, got) + }, retryDuration, tick) + }) + } +} diff --git a/internal/api/grpc/session/v2/integration_test/server_test.go b/internal/api/grpc/session/v2/integration_test/server_test.go new file mode 100644 index 0000000000..70e2146069 --- /dev/null +++ b/internal/api/grpc/session/v2/integration_test/server_test.go @@ -0,0 +1,74 @@ +//go:build integration + +package session_test + +import ( + "context" + "os" + "testing" + "time" + + "github.com/zitadel/logging" + + "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/pkg/grpc/session/v2" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" +) + +var ( + CTX context.Context + IAMOwnerCTX context.Context + UserCTX context.Context + Instance *integration.Instance + Client session.SessionServiceClient + User *user.AddHumanUserResponse + DeactivatedUser *user.AddHumanUserResponse + LockedUser *user.AddHumanUserResponse +) + +func TestMain(m *testing.M) { + os.Exit(func() int { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + Instance = integration.NewInstance(ctx) + Client = Instance.Client.SessionV2 + + CTX = Instance.WithAuthorization(ctx, integration.UserTypeOrgOwner) + IAMOwnerCTX = Instance.WithAuthorization(ctx, integration.UserTypeIAMOwner) + UserCTX = Instance.WithAuthorization(ctx, integration.UserTypeNoPermission) + User = createFullUser(CTX) + DeactivatedUser = createDeactivatedUser(CTX) + LockedUser = createLockedUser(CTX) + return m.Run() + }()) +} + +func createFullUser(ctx context.Context) *user.AddHumanUserResponse { + userResp := Instance.CreateHumanUser(ctx) + Instance.Client.UserV2.VerifyEmail(ctx, &user.VerifyEmailRequest{ + UserId: userResp.GetUserId(), + VerificationCode: userResp.GetEmailCode(), + }) + Instance.Client.UserV2.VerifyPhone(ctx, &user.VerifyPhoneRequest{ + UserId: userResp.GetUserId(), + VerificationCode: userResp.GetPhoneCode(), + }) + Instance.SetUserPassword(ctx, userResp.GetUserId(), integration.UserPassword, false) + Instance.RegisterUserPasskey(ctx, userResp.GetUserId()) + return userResp +} + +func createDeactivatedUser(ctx context.Context) *user.AddHumanUserResponse { + userResp := Instance.CreateHumanUser(ctx) + _, err := Instance.Client.UserV2.DeactivateUser(ctx, &user.DeactivateUserRequest{UserId: userResp.GetUserId()}) + logging.OnError(err).Fatal("deactivate human user") + return userResp +} + +func createLockedUser(ctx context.Context) *user.AddHumanUserResponse { + userResp := Instance.CreateHumanUser(ctx) + _, err := Instance.Client.UserV2.LockUser(ctx, &user.LockUserRequest{UserId: userResp.GetUserId()}) + logging.OnError(err).Fatal("lock human user") + return userResp +} diff --git a/internal/api/grpc/session/v2/integration_test/session_test.go b/internal/api/grpc/session/v2/integration_test/session_test.go index ccd08f3471..7622550b15 100644 --- a/internal/api/grpc/session/v2/integration_test/session_test.go +++ b/internal/api/grpc/session/v2/integration_test/session_test.go @@ -5,7 +5,6 @@ package session_test import ( "context" "fmt" - "os" "testing" "time" @@ -14,7 +13,6 @@ import ( "github.com/pquerna/otp/totp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/zitadel/logging" "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" @@ -29,63 +27,7 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) -var ( - CTX context.Context - IAMOwnerCTX context.Context - Instance *integration.Instance - Client session.SessionServiceClient - User *user.AddHumanUserResponse - DeactivatedUser *user.AddHumanUserResponse - LockedUser *user.AddHumanUserResponse -) - -func TestMain(m *testing.M) { - os.Exit(func() int { - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) - defer cancel() - - Instance = integration.NewInstance(ctx) - Client = Instance.Client.SessionV2 - - CTX = Instance.WithAuthorization(ctx, integration.UserTypeOrgOwner) - IAMOwnerCTX = Instance.WithAuthorization(ctx, integration.UserTypeIAMOwner) - User = createFullUser(CTX) - DeactivatedUser = createDeactivatedUser(CTX) - LockedUser = createLockedUser(CTX) - return m.Run() - }()) -} - -func createFullUser(ctx context.Context) *user.AddHumanUserResponse { - userResp := Instance.CreateHumanUser(ctx) - Instance.Client.UserV2.VerifyEmail(ctx, &user.VerifyEmailRequest{ - UserId: userResp.GetUserId(), - VerificationCode: userResp.GetEmailCode(), - }) - Instance.Client.UserV2.VerifyPhone(ctx, &user.VerifyPhoneRequest{ - UserId: userResp.GetUserId(), - VerificationCode: userResp.GetPhoneCode(), - }) - Instance.SetUserPassword(ctx, userResp.GetUserId(), integration.UserPassword, false) - Instance.RegisterUserPasskey(ctx, userResp.GetUserId()) - return userResp -} - -func createDeactivatedUser(ctx context.Context) *user.AddHumanUserResponse { - userResp := Instance.CreateHumanUser(ctx) - _, err := Instance.Client.UserV2.DeactivateUser(ctx, &user.DeactivateUserRequest{UserId: userResp.GetUserId()}) - logging.OnError(err).Fatal("deactivate human user") - return userResp -} - -func createLockedUser(ctx context.Context) *user.AddHumanUserResponse { - userResp := Instance.CreateHumanUser(ctx) - _, err := Instance.Client.UserV2.LockUser(ctx, &user.LockUserRequest{UserId: userResp.GetUserId()}) - logging.OnError(err).Fatal("lock human user") - return userResp -} - -func verifyCurrentSession(t testing.TB, id, token string, sequence uint64, window time.Duration, metadata map[string][]byte, userAgent *session.UserAgent, expirationWindow time.Duration, userID string, factors ...wantFactor) *session.Session { +func verifyCurrentSession(t *testing.T, id, token string, sequence uint64, window time.Duration, metadata map[string][]byte, userAgent *session.UserAgent, expirationWindow time.Duration, userID string, factors ...wantFactor) *session.Session { t.Helper() require.NotEmpty(t, id) require.NotEmpty(t, token) @@ -96,15 +38,25 @@ func verifyCurrentSession(t testing.TB, id, token string, sequence uint64, windo }) require.NoError(t, err) s := resp.GetSession() + want := &session.Session{ + Id: id, + Sequence: sequence, + Metadata: metadata, + UserAgent: userAgent, + } + verifySession(t, s, want, window, expirationWindow, userID, factors...) + return s +} - assert.Equal(t, id, s.GetId()) +func verifySession(t assert.TestingT, s *session.Session, want *session.Session, window time.Duration, expirationWindow time.Duration, userID string, factors ...wantFactor) { + assert.Equal(t, want.Id, s.GetId()) assert.WithinRange(t, s.GetCreationDate().AsTime(), time.Now().Add(-window), time.Now().Add(window)) assert.WithinRange(t, s.GetChangeDate().AsTime(), time.Now().Add(-window), time.Now().Add(window)) - assert.Equal(t, sequence, s.GetSequence()) - assert.Equal(t, metadata, s.GetMetadata()) + assert.Equal(t, want.Sequence, s.GetSequence()) + assert.Equal(t, want.Metadata, s.GetMetadata()) - if !proto.Equal(userAgent, s.GetUserAgent()) { - t.Errorf("user agent =\n%v\nwant\n%v", s.GetUserAgent(), userAgent) + if !proto.Equal(want.UserAgent, s.GetUserAgent()) { + t.Errorf("user agent =\n%v\nwant\n%v", s.GetUserAgent(), want.UserAgent) } if expirationWindow == 0 { assert.Nil(t, s.GetExpirationDate()) @@ -113,7 +65,6 @@ func verifyCurrentSession(t testing.TB, id, token string, sequence uint64, windo } verifyFactors(t, s.GetFactors(), window, userID, factors) - return s } type wantFactor int @@ -129,7 +80,7 @@ const ( wantOTPEmailFactor ) -func verifyFactors(t testing.TB, factors *session.Factors, window time.Duration, userID string, want []wantFactor) { +func verifyFactors(t assert.TestingT, factors *session.Factors, window time.Duration, userID string, want []wantFactor) { for _, w := range want { switch w { case wantUserFactor: @@ -194,8 +145,15 @@ func TestServer_CreateSession(t *testing.T) { }, }, { - name: "user agent", + name: "full session", req: &session.CreateSessionRequest{ + Checks: &session.Checks{ + User: &session.CheckUser{ + Search: &session.CheckUser_UserId{ + UserId: User.GetUserId(), + }, + }, + }, Metadata: map[string][]byte{"foo": []byte("bar")}, UserAgent: &session.UserAgent{ FingerprintId: gu.Ptr("fingerPrintID"), @@ -205,6 +163,7 @@ func TestServer_CreateSession(t *testing.T) { "foo": {Values: []string{"foo", "bar"}}, }, }, + Lifetime: durationpb.New(5 * time.Minute), }, want: &session.CreateSessionResponse{ Details: &object.Details{ @@ -212,14 +171,6 @@ func TestServer_CreateSession(t *testing.T) { ResourceOwner: Instance.ID(), }, }, - wantUserAgent: &session.UserAgent{ - FingerprintId: gu.Ptr("fingerPrintID"), - Ip: gu.Ptr("1.2.3.4"), - Description: gu.Ptr("Description"), - Header: map[string]*session.UserAgent_HeaderValues{ - "foo": {Values: []string{"foo", "bar"}}, - }, - }, }, { name: "negative lifetime", @@ -229,40 +180,6 @@ func TestServer_CreateSession(t *testing.T) { }, wantErr: true, }, - { - name: "lifetime", - req: &session.CreateSessionRequest{ - Metadata: map[string][]byte{"foo": []byte("bar")}, - Lifetime: durationpb.New(5 * time.Minute), - }, - want: &session.CreateSessionResponse{ - Details: &object.Details{ - ChangeDate: timestamppb.Now(), - ResourceOwner: Instance.ID(), - }, - }, - wantExpirationWindow: 5 * time.Minute, - }, - { - name: "with user", - req: &session.CreateSessionRequest{ - Checks: &session.Checks{ - User: &session.CheckUser{ - Search: &session.CheckUser_UserId{ - UserId: User.GetUserId(), - }, - }, - }, - Metadata: map[string][]byte{"foo": []byte("bar")}, - }, - want: &session.CreateSessionResponse{ - Details: &object.Details{ - ChangeDate: timestamppb.Now(), - ResourceOwner: Instance.ID(), - }, - }, - wantFactors: []wantFactor{wantUserFactor}, - }, { name: "deactivated user", req: &session.CreateSessionRequest{ @@ -340,8 +257,6 @@ func TestServer_CreateSession(t *testing.T) { } require.NoError(t, err) integration.AssertDetails(t, tt.want, got) - - verifyCurrentSession(t, got.GetSessionId(), got.GetSessionToken(), got.GetDetails().GetSequence(), time.Minute, tt.req.GetMetadata(), tt.wantUserAgent, tt.wantExpirationWindow, User.GetUserId(), tt.wantFactors...) }) } } @@ -946,21 +861,30 @@ func Test_ZITADEL_API_missing_authentication(t *testing.T) { require.NoError(t, err) ctx := metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("Bearer %s", createResp.GetSessionToken())) - sessionResp, err := Client.GetSession(ctx, &session.GetSessionRequest{SessionId: createResp.GetSessionId()}) - require.Error(t, err) - require.Nil(t, sessionResp) + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(ctx, time.Minute) + require.EventuallyWithT(t, func(tt *assert.CollectT) { + sessionResp, err := Client.GetSession(ctx, &session.GetSessionRequest{SessionId: createResp.GetSessionId()}) + if !assert.Error(tt, err) { + return + } + assert.Nil(tt, sessionResp) + }, retryDuration, tick) } func Test_ZITADEL_API_success(t *testing.T) { id, token, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, User.GetUserId()) - ctx := integration.WithAuthorizationToken(context.Background(), token) - sessionResp, err := Client.GetSession(ctx, &session.GetSessionRequest{SessionId: id}) - require.NoError(t, err) - webAuthN := sessionResp.GetSession().GetFactors().GetWebAuthN() - require.NotNil(t, id, webAuthN.GetVerifiedAt().AsTime()) - require.True(t, webAuthN.GetUserVerified()) + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(ctx, time.Minute) + require.EventuallyWithT(t, func(tt *assert.CollectT) { + sessionResp, err := Client.GetSession(ctx, &session.GetSessionRequest{SessionId: id}) + if !assert.NoError(tt, err) { + return + } + webAuthN := sessionResp.GetSession().GetFactors().GetWebAuthN() + assert.NotNil(tt, id, webAuthN.GetVerifiedAt().AsTime()) + assert.True(tt, webAuthN.GetUserVerified()) + }, retryDuration, tick) } func Test_ZITADEL_API_session_not_found(t *testing.T) { @@ -968,18 +892,30 @@ func Test_ZITADEL_API_session_not_found(t *testing.T) { // test session token works ctx := integration.WithAuthorizationToken(context.Background(), token) - _, err := Client.GetSession(ctx, &session.GetSessionRequest{SessionId: id}) - require.NoError(t, err) + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(ctx, time.Minute) + require.EventuallyWithT(t, func(tt *assert.CollectT) { + _, err := Client.GetSession(ctx, &session.GetSessionRequest{SessionId: id}) + if !assert.NoError(tt, err) { + return + } + }, retryDuration, tick) //terminate the session and test it does not work anymore - _, err = Client.DeleteSession(CTX, &session.DeleteSessionRequest{ + _, err := Client.DeleteSession(CTX, &session.DeleteSessionRequest{ SessionId: id, SessionToken: gu.Ptr(token), }) require.NoError(t, err) + ctx = integration.WithAuthorizationToken(context.Background(), token) - _, err = Client.GetSession(ctx, &session.GetSessionRequest{SessionId: id}) - require.Error(t, err) + retryDuration, tick = integration.WaitForAndTickWithMaxDuration(ctx, time.Minute) + require.EventuallyWithT(t, func(tt *assert.CollectT) { + _, err := Client.GetSession(ctx, &session.GetSessionRequest{SessionId: id}) + if !assert.Error(tt, err) { + return + } + }, retryDuration, tick) } func Test_ZITADEL_API_session_expired(t *testing.T) { @@ -987,8 +923,13 @@ func Test_ZITADEL_API_session_expired(t *testing.T) { // test session token works ctx := integration.WithAuthorizationToken(context.Background(), token) - _, err := Client.GetSession(ctx, &session.GetSessionRequest{SessionId: id}) - require.NoError(t, err) + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(ctx, time.Minute) + require.EventuallyWithT(t, func(tt *assert.CollectT) { + _, err := Client.GetSession(ctx, &session.GetSessionRequest{SessionId: id}) + if !assert.NoError(tt, err) { + return + } + }, retryDuration, tick) // ensure session expires and does not work anymore time.Sleep(20 * time.Second) diff --git a/internal/api/grpc/session/v2/query.go b/internal/api/grpc/session/v2/query.go new file mode 100644 index 0000000000..73303dd9e8 --- /dev/null +++ b/internal/api/grpc/session/v2/query.go @@ -0,0 +1,262 @@ +package session + +import ( + "context" + "time" + + "github.com/muhlemmer/gu" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/api/grpc/object/v2" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/zerrors" + objpb "github.com/zitadel/zitadel/pkg/grpc/object" + "github.com/zitadel/zitadel/pkg/grpc/session/v2" +) + +var ( + timestampComparisons = map[objpb.TimestampQueryMethod]query.TimestampComparison{ + objpb.TimestampQueryMethod_TIMESTAMP_QUERY_METHOD_EQUALS: query.TimestampEquals, + objpb.TimestampQueryMethod_TIMESTAMP_QUERY_METHOD_GREATER: query.TimestampGreater, + objpb.TimestampQueryMethod_TIMESTAMP_QUERY_METHOD_GREATER_OR_EQUALS: query.TimestampGreaterOrEquals, + objpb.TimestampQueryMethod_TIMESTAMP_QUERY_METHOD_LESS: query.TimestampLess, + objpb.TimestampQueryMethod_TIMESTAMP_QUERY_METHOD_LESS_OR_EQUALS: query.TimestampLessOrEquals, + } +) + +func (s *Server) GetSession(ctx context.Context, req *session.GetSessionRequest) (*session.GetSessionResponse, error) { + res, err := s.query.SessionByID(ctx, true, req.GetSessionId(), req.GetSessionToken(), s.checkPermission) + if err != nil { + return nil, err + } + return &session.GetSessionResponse{ + Session: sessionToPb(res), + }, nil +} + +func (s *Server) ListSessions(ctx context.Context, req *session.ListSessionsRequest) (*session.ListSessionsResponse, error) { + queries, err := listSessionsRequestToQuery(ctx, req) + if err != nil { + return nil, err + } + sessions, err := s.query.SearchSessions(ctx, queries, s.checkPermission) + if err != nil { + return nil, err + } + return &session.ListSessionsResponse{ + Details: object.ToListDetails(sessions.SearchResponse), + Sessions: sessionsToPb(sessions.Sessions), + }, nil +} + +func listSessionsRequestToQuery(ctx context.Context, req *session.ListSessionsRequest) (*query.SessionsSearchQueries, error) { + offset, limit, asc := object.ListQueryToQuery(req.Query) + queries, err := sessionQueriesToQuery(ctx, req.GetQueries()) + if err != nil { + return nil, err + } + return &query.SessionsSearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: offset, + Limit: limit, + Asc: asc, + SortingColumn: fieldNameToSessionColumn(req.GetSortingColumn()), + }, + Queries: queries, + }, nil +} + +func sessionQueriesToQuery(ctx context.Context, queries []*session.SearchQuery) (_ []query.SearchQuery, err error) { + q := make([]query.SearchQuery, len(queries)) + for i, v := range queries { + q[i], err = sessionQueryToQuery(ctx, v) + if err != nil { + return nil, err + } + } + return q, nil +} + +func sessionQueryToQuery(ctx context.Context, sq *session.SearchQuery) (query.SearchQuery, error) { + switch q := sq.Query.(type) { + case *session.SearchQuery_IdsQuery: + return idsQueryToQuery(q.IdsQuery) + case *session.SearchQuery_UserIdQuery: + return query.NewUserIDSearchQuery(q.UserIdQuery.GetId()) + case *session.SearchQuery_CreationDateQuery: + return creationDateQueryToQuery(q.CreationDateQuery) + case *session.SearchQuery_CreatorQuery: + if q.CreatorQuery != nil && q.CreatorQuery.Id != nil { + if q.CreatorQuery.GetId() != "" { + return query.NewSessionCreatorSearchQuery(q.CreatorQuery.GetId()) + } + } else { + if userID := authz.GetCtxData(ctx).UserID; userID != "" { + return query.NewSessionCreatorSearchQuery(userID) + } + } + return nil, zerrors.ThrowInvalidArgument(nil, "GRPC-x8n24uh", "List.Query.Invalid") + case *session.SearchQuery_UserAgentQuery: + if q.UserAgentQuery != nil && q.UserAgentQuery.FingerprintId != nil { + if *q.UserAgentQuery.FingerprintId != "" { + return query.NewSessionUserAgentFingerprintIDSearchQuery(q.UserAgentQuery.GetFingerprintId()) + } + } else { + if agentID := authz.GetCtxData(ctx).AgentID; agentID != "" { + return query.NewSessionUserAgentFingerprintIDSearchQuery(agentID) + } + } + return nil, zerrors.ThrowInvalidArgument(nil, "GRPC-x8n23uh", "List.Query.Invalid") + default: + return nil, zerrors.ThrowInvalidArgument(nil, "GRPC-Sfefs", "List.Query.Invalid") + } +} + +func idsQueryToQuery(q *session.IDsQuery) (query.SearchQuery, error) { + return query.NewSessionIDsSearchQuery(q.Ids) +} + +func creationDateQueryToQuery(q *session.CreationDateQuery) (query.SearchQuery, error) { + comparison := timestampComparisons[q.GetMethod()] + return query.NewCreationDateQuery(q.GetCreationDate().AsTime(), comparison) +} + +func fieldNameToSessionColumn(field session.SessionFieldName) query.Column { + switch field { + case session.SessionFieldName_SESSION_FIELD_NAME_CREATION_DATE: + return query.SessionColumnCreationDate + case session.SessionFieldName_SESSION_FIELD_NAME_UNSPECIFIED: + return query.Column{} + default: + return query.Column{} + } +} + +func sessionsToPb(sessions []*query.Session) []*session.Session { + s := make([]*session.Session, len(sessions)) + for i, session := range sessions { + s[i] = sessionToPb(session) + } + return s +} + +func sessionToPb(s *query.Session) *session.Session { + return &session.Session{ + Id: s.ID, + CreationDate: timestamppb.New(s.CreationDate), + ChangeDate: timestamppb.New(s.ChangeDate), + Sequence: s.Sequence, + Factors: factorsToPb(s), + Metadata: s.Metadata, + UserAgent: userAgentToPb(s.UserAgent), + ExpirationDate: expirationToPb(s.Expiration), + } +} + +func userAgentToPb(ua domain.UserAgent) *session.UserAgent { + if ua.IsEmpty() { + return nil + } + + out := &session.UserAgent{ + FingerprintId: ua.FingerprintID, + Description: ua.Description, + } + if ua.IP != nil { + out.Ip = gu.Ptr(ua.IP.String()) + } + if ua.Header == nil { + return out + } + out.Header = make(map[string]*session.UserAgent_HeaderValues, len(ua.Header)) + for k, v := range ua.Header { + out.Header[k] = &session.UserAgent_HeaderValues{ + Values: v, + } + } + return out +} + +func expirationToPb(expiration time.Time) *timestamppb.Timestamp { + if expiration.IsZero() { + return nil + } + return timestamppb.New(expiration) +} + +func factorsToPb(s *query.Session) *session.Factors { + user := userFactorToPb(s.UserFactor) + if user == nil { + return nil + } + return &session.Factors{ + User: user, + Password: passwordFactorToPb(s.PasswordFactor), + WebAuthN: webAuthNFactorToPb(s.WebAuthNFactor), + Intent: intentFactorToPb(s.IntentFactor), + Totp: totpFactorToPb(s.TOTPFactor), + OtpSms: otpFactorToPb(s.OTPSMSFactor), + OtpEmail: otpFactorToPb(s.OTPEmailFactor), + } +} + +func passwordFactorToPb(factor query.SessionPasswordFactor) *session.PasswordFactor { + if factor.PasswordCheckedAt.IsZero() { + return nil + } + return &session.PasswordFactor{ + VerifiedAt: timestamppb.New(factor.PasswordCheckedAt), + } +} + +func intentFactorToPb(factor query.SessionIntentFactor) *session.IntentFactor { + if factor.IntentCheckedAt.IsZero() { + return nil + } + return &session.IntentFactor{ + VerifiedAt: timestamppb.New(factor.IntentCheckedAt), + } +} + +func webAuthNFactorToPb(factor query.SessionWebAuthNFactor) *session.WebAuthNFactor { + if factor.WebAuthNCheckedAt.IsZero() { + return nil + } + return &session.WebAuthNFactor{ + VerifiedAt: timestamppb.New(factor.WebAuthNCheckedAt), + UserVerified: factor.UserVerified, + } +} + +func totpFactorToPb(factor query.SessionTOTPFactor) *session.TOTPFactor { + if factor.TOTPCheckedAt.IsZero() { + return nil + } + return &session.TOTPFactor{ + VerifiedAt: timestamppb.New(factor.TOTPCheckedAt), + } +} + +func otpFactorToPb(factor query.SessionOTPFactor) *session.OTPFactor { + if factor.OTPCheckedAt.IsZero() { + return nil + } + return &session.OTPFactor{ + VerifiedAt: timestamppb.New(factor.OTPCheckedAt), + } +} + +func userFactorToPb(factor query.SessionUserFactor) *session.UserFactor { + if factor.UserID == "" || factor.UserCheckedAt.IsZero() { + return nil + } + return &session.UserFactor{ + VerifiedAt: timestamppb.New(factor.UserCheckedAt), + Id: factor.UserID, + LoginName: factor.LoginName, + DisplayName: factor.DisplayName, + OrganizationId: factor.ResourceOwner, + } +} diff --git a/internal/api/grpc/session/v2/server.go b/internal/api/grpc/session/v2/server.go index e94336bf47..ee534cb26c 100644 --- a/internal/api/grpc/session/v2/server.go +++ b/internal/api/grpc/session/v2/server.go @@ -6,6 +6,7 @@ import ( "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/grpc/server" "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/pkg/grpc/session/v2" ) @@ -16,6 +17,8 @@ type Server struct { session.UnimplementedSessionServiceServer command *command.Commands query *query.Queries + + checkPermission domain.PermissionCheck } type Config struct{} @@ -23,10 +26,12 @@ type Config struct{} func CreateServer( command *command.Commands, query *query.Queries, + checkPermission domain.PermissionCheck, ) *Server { return &Server{ - command: command, - query: query, + command: command, + query: query, + checkPermission: checkPermission, } } diff --git a/internal/api/grpc/session/v2/session.go b/internal/api/grpc/session/v2/session.go index aa25fa0ae3..7562d64350 100644 --- a/internal/api/grpc/session/v2/session.go +++ b/internal/api/grpc/session/v2/session.go @@ -6,56 +6,17 @@ import ( "net/http" "time" - "github.com/muhlemmer/gu" "golang.org/x/text/language" "google.golang.org/protobuf/types/known/structpb" - "google.golang.org/protobuf/types/known/timestamppb" - "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/grpc/object/v2" "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/zerrors" - objpb "github.com/zitadel/zitadel/pkg/grpc/object" "github.com/zitadel/zitadel/pkg/grpc/session/v2" ) -var ( - timestampComparisons = map[objpb.TimestampQueryMethod]query.TimestampComparison{ - objpb.TimestampQueryMethod_TIMESTAMP_QUERY_METHOD_EQUALS: query.TimestampEquals, - objpb.TimestampQueryMethod_TIMESTAMP_QUERY_METHOD_GREATER: query.TimestampGreater, - objpb.TimestampQueryMethod_TIMESTAMP_QUERY_METHOD_GREATER_OR_EQUALS: query.TimestampGreaterOrEquals, - objpb.TimestampQueryMethod_TIMESTAMP_QUERY_METHOD_LESS: query.TimestampLess, - objpb.TimestampQueryMethod_TIMESTAMP_QUERY_METHOD_LESS_OR_EQUALS: query.TimestampLessOrEquals, - } -) - -func (s *Server) GetSession(ctx context.Context, req *session.GetSessionRequest) (*session.GetSessionResponse, error) { - res, err := s.query.SessionByID(ctx, true, req.GetSessionId(), req.GetSessionToken()) - if err != nil { - return nil, err - } - return &session.GetSessionResponse{ - Session: sessionToPb(res), - }, nil -} - -func (s *Server) ListSessions(ctx context.Context, req *session.ListSessionsRequest) (*session.ListSessionsResponse, error) { - queries, err := listSessionsRequestToQuery(ctx, req) - if err != nil { - return nil, err - } - sessions, err := s.query.SearchSessions(ctx, queries) - if err != nil { - return nil, err - } - return &session.ListSessionsResponse{ - Details: object.ToListDetails(sessions.SearchResponse), - Sessions: sessionsToPb(sessions.Sessions), - }, nil -} - func (s *Server) CreateSession(ctx context.Context, req *session.CreateSessionRequest) (*session.CreateSessionResponse, error) { checks, metadata, userAgent, lifetime, err := s.createSessionRequestToCommand(ctx, req) if err != nil { @@ -110,197 +71,6 @@ func (s *Server) DeleteSession(ctx context.Context, req *session.DeleteSessionRe }, nil } -func sessionsToPb(sessions []*query.Session) []*session.Session { - s := make([]*session.Session, len(sessions)) - for i, session := range sessions { - s[i] = sessionToPb(session) - } - return s -} - -func sessionToPb(s *query.Session) *session.Session { - return &session.Session{ - Id: s.ID, - CreationDate: timestamppb.New(s.CreationDate), - ChangeDate: timestamppb.New(s.ChangeDate), - Sequence: s.Sequence, - Factors: factorsToPb(s), - Metadata: s.Metadata, - UserAgent: userAgentToPb(s.UserAgent), - ExpirationDate: expirationToPb(s.Expiration), - } -} - -func userAgentToPb(ua domain.UserAgent) *session.UserAgent { - if ua.IsEmpty() { - return nil - } - - out := &session.UserAgent{ - FingerprintId: ua.FingerprintID, - Description: ua.Description, - } - if ua.IP != nil { - out.Ip = gu.Ptr(ua.IP.String()) - } - if ua.Header == nil { - return out - } - out.Header = make(map[string]*session.UserAgent_HeaderValues, len(ua.Header)) - for k, v := range ua.Header { - out.Header[k] = &session.UserAgent_HeaderValues{ - Values: v, - } - } - return out -} - -func expirationToPb(expiration time.Time) *timestamppb.Timestamp { - if expiration.IsZero() { - return nil - } - return timestamppb.New(expiration) -} - -func factorsToPb(s *query.Session) *session.Factors { - user := userFactorToPb(s.UserFactor) - if user == nil { - return nil - } - return &session.Factors{ - User: user, - Password: passwordFactorToPb(s.PasswordFactor), - WebAuthN: webAuthNFactorToPb(s.WebAuthNFactor), - Intent: intentFactorToPb(s.IntentFactor), - Totp: totpFactorToPb(s.TOTPFactor), - OtpSms: otpFactorToPb(s.OTPSMSFactor), - OtpEmail: otpFactorToPb(s.OTPEmailFactor), - } -} - -func passwordFactorToPb(factor query.SessionPasswordFactor) *session.PasswordFactor { - if factor.PasswordCheckedAt.IsZero() { - return nil - } - return &session.PasswordFactor{ - VerifiedAt: timestamppb.New(factor.PasswordCheckedAt), - } -} - -func intentFactorToPb(factor query.SessionIntentFactor) *session.IntentFactor { - if factor.IntentCheckedAt.IsZero() { - return nil - } - return &session.IntentFactor{ - VerifiedAt: timestamppb.New(factor.IntentCheckedAt), - } -} - -func webAuthNFactorToPb(factor query.SessionWebAuthNFactor) *session.WebAuthNFactor { - if factor.WebAuthNCheckedAt.IsZero() { - return nil - } - return &session.WebAuthNFactor{ - VerifiedAt: timestamppb.New(factor.WebAuthNCheckedAt), - UserVerified: factor.UserVerified, - } -} - -func totpFactorToPb(factor query.SessionTOTPFactor) *session.TOTPFactor { - if factor.TOTPCheckedAt.IsZero() { - return nil - } - return &session.TOTPFactor{ - VerifiedAt: timestamppb.New(factor.TOTPCheckedAt), - } -} - -func otpFactorToPb(factor query.SessionOTPFactor) *session.OTPFactor { - if factor.OTPCheckedAt.IsZero() { - return nil - } - return &session.OTPFactor{ - VerifiedAt: timestamppb.New(factor.OTPCheckedAt), - } -} - -func userFactorToPb(factor query.SessionUserFactor) *session.UserFactor { - if factor.UserID == "" || factor.UserCheckedAt.IsZero() { - return nil - } - return &session.UserFactor{ - VerifiedAt: timestamppb.New(factor.UserCheckedAt), - Id: factor.UserID, - LoginName: factor.LoginName, - DisplayName: factor.DisplayName, - OrganizationId: factor.ResourceOwner, - } -} - -func listSessionsRequestToQuery(ctx context.Context, req *session.ListSessionsRequest) (*query.SessionsSearchQueries, error) { - offset, limit, asc := object.ListQueryToQuery(req.Query) - queries, err := sessionQueriesToQuery(ctx, req.GetQueries()) - if err != nil { - return nil, err - } - return &query.SessionsSearchQueries{ - SearchRequest: query.SearchRequest{ - Offset: offset, - Limit: limit, - Asc: asc, - SortingColumn: fieldNameToSessionColumn(req.GetSortingColumn()), - }, - Queries: queries, - }, nil -} - -func sessionQueriesToQuery(ctx context.Context, queries []*session.SearchQuery) (_ []query.SearchQuery, err error) { - q := make([]query.SearchQuery, len(queries)+1) - for i, v := range queries { - q[i], err = sessionQueryToQuery(v) - if err != nil { - return nil, err - } - } - creatorQuery, err := query.NewSessionCreatorSearchQuery(authz.GetCtxData(ctx).UserID) - if err != nil { - return nil, err - } - q[len(queries)] = creatorQuery - return q, nil -} - -func sessionQueryToQuery(sq *session.SearchQuery) (query.SearchQuery, error) { - switch q := sq.Query.(type) { - case *session.SearchQuery_IdsQuery: - return idsQueryToQuery(q.IdsQuery) - case *session.SearchQuery_UserIdQuery: - return query.NewUserIDSearchQuery(q.UserIdQuery.GetId()) - case *session.SearchQuery_CreationDateQuery: - return creationDateQueryToQuery(q.CreationDateQuery) - default: - return nil, zerrors.ThrowInvalidArgument(nil, "GRPC-Sfefs", "List.Query.Invalid") - } -} - -func idsQueryToQuery(q *session.IDsQuery) (query.SearchQuery, error) { - return query.NewSessionIDsSearchQuery(q.Ids) -} - -func creationDateQueryToQuery(q *session.CreationDateQuery) (query.SearchQuery, error) { - comparison := timestampComparisons[q.GetMethod()] - return query.NewCreationDateQuery(q.GetCreationDate().AsTime(), comparison) -} - -func fieldNameToSessionColumn(field session.SessionFieldName) query.Column { - switch field { - case session.SessionFieldName_SESSION_FIELD_NAME_CREATION_DATE: - return query.SessionColumnCreationDate - default: - return query.Column{} - } -} - func (s *Server) createSessionRequestToCommand(ctx context.Context, req *session.CreateSessionRequest) ([]command.SessionCommand, map[string][]byte, *domain.UserAgent, time.Duration, error) { checks, err := s.checksToCommand(ctx, req.Checks) if err != nil { diff --git a/internal/api/grpc/session/v2/session_test.go b/internal/api/grpc/session/v2/session_test.go index 917be882f8..ce4f5115f2 100644 --- a/internal/api/grpc/session/v2/session_test.go +++ b/internal/api/grpc/session/v2/session_test.go @@ -339,9 +339,7 @@ func Test_listSessionsRequestToQuery(t *testing.T) { Limit: 0, Asc: false, }, - Queries: []query.SearchQuery{ - mustNewTextQuery(t, query.SessionColumnCreator, "789", query.TextEquals), - }, + Queries: []query.SearchQuery{}, }, }, { @@ -359,15 +357,13 @@ func Test_listSessionsRequestToQuery(t *testing.T) { SortingColumn: query.SessionColumnCreationDate, Asc: false, }, - Queries: []query.SearchQuery{ - mustNewTextQuery(t, query.SessionColumnCreator, "789", query.TextEquals), - }, + Queries: []query.SearchQuery{}, }, }, { name: "with list query and sessions", args: args{ - ctx: authz.NewMockContext("123", "456", "789"), + ctx: authz.SetCtxData(context.Background(), authz.CtxData{AgentID: "agent", UserID: "789"}), req: &session.ListSessionsRequest{ Query: &object.ListQuery{ Offset: 10, @@ -396,6 +392,12 @@ func Test_listSessionsRequestToQuery(t *testing.T) { Method: objpb.TimestampQueryMethod_TIMESTAMP_QUERY_METHOD_GREATER, }, }}, + {Query: &session.SearchQuery_CreatorQuery{ + CreatorQuery: &session.CreatorQuery{}, + }}, + {Query: &session.SearchQuery_UserAgentQuery{ + UserAgentQuery: &session.UserAgentQuery{}, + }}, }, }, }, @@ -411,6 +413,7 @@ func Test_listSessionsRequestToQuery(t *testing.T) { mustNewTextQuery(t, query.SessionColumnUserID, "10", query.TextEquals), mustNewTimestampQuery(t, query.SessionColumnCreationDate, creationDate, query.TimestampGreater), mustNewTextQuery(t, query.SessionColumnCreator, "789", query.TextEquals), + mustNewTextQuery(t, query.SessionColumnUserAgentFingerprintID, "agent", query.TextEquals), }, }, }, @@ -458,13 +461,11 @@ func Test_sessionQueriesToQuery(t *testing.T) { wantErr error }{ { - name: "creator only", + name: "no queries", args: args{ ctx: authz.NewMockContext("123", "456", "789"), }, - want: []query.SearchQuery{ - mustNewTextQuery(t, query.SessionColumnCreator, "789", query.TextEquals), - }, + want: []query.SearchQuery{}, }, { name: "invalid argument", @@ -491,6 +492,9 @@ func Test_sessionQueriesToQuery(t *testing.T) { Ids: []string{"4", "5", "6"}, }, }}, + {Query: &session.SearchQuery_CreatorQuery{ + CreatorQuery: &session.CreatorQuery{}, + }}, }, }, want: []query.SearchQuery{ @@ -511,6 +515,7 @@ func Test_sessionQueriesToQuery(t *testing.T) { func Test_sessionQueryToQuery(t *testing.T) { type args struct { + ctx context.Context query *session.SearchQuery } tests := []struct { @@ -521,60 +526,158 @@ func Test_sessionQueryToQuery(t *testing.T) { }{ { name: "invalid argument", - args: args{&session.SearchQuery{ - Query: nil, - }}, + args: args{ + context.Background(), + &session.SearchQuery{ + Query: nil, + }}, wantErr: zerrors.ThrowInvalidArgument(nil, "GRPC-Sfefs", "List.Query.Invalid"), }, { name: "ids query", - args: args{&session.SearchQuery{ - Query: &session.SearchQuery_IdsQuery{ - IdsQuery: &session.IDsQuery{ - Ids: []string{"1", "2", "3"}, + args: args{ + context.Background(), + &session.SearchQuery{ + Query: &session.SearchQuery_IdsQuery{ + IdsQuery: &session.IDsQuery{ + Ids: []string{"1", "2", "3"}, + }, }, - }, - }}, + }}, want: mustNewListQuery(t, query.SessionColumnID, []interface{}{"1", "2", "3"}, query.ListIn), }, { name: "user id query", - args: args{&session.SearchQuery{ - Query: &session.SearchQuery_UserIdQuery{ - UserIdQuery: &session.UserIDQuery{ - Id: "10", + args: args{ + context.Background(), + &session.SearchQuery{ + Query: &session.SearchQuery_UserIdQuery{ + UserIdQuery: &session.UserIDQuery{ + Id: "10", + }, }, - }, - }}, + }}, want: mustNewTextQuery(t, query.SessionColumnUserID, "10", query.TextEquals), }, { name: "creation date query", - args: args{&session.SearchQuery{ - Query: &session.SearchQuery_CreationDateQuery{ - CreationDateQuery: &session.CreationDateQuery{ - CreationDate: timestamppb.New(creationDate), - Method: objpb.TimestampQueryMethod_TIMESTAMP_QUERY_METHOD_LESS, + args: args{ + context.Background(), + &session.SearchQuery{ + Query: &session.SearchQuery_CreationDateQuery{ + CreationDateQuery: &session.CreationDateQuery{ + CreationDate: timestamppb.New(creationDate), + Method: objpb.TimestampQueryMethod_TIMESTAMP_QUERY_METHOD_LESS, + }, }, - }, - }}, + }}, want: mustNewTimestampQuery(t, query.SessionColumnCreationDate, creationDate, query.TimestampLess), }, { name: "creation date query with default method", - args: args{&session.SearchQuery{ - Query: &session.SearchQuery_CreationDateQuery{ - CreationDateQuery: &session.CreationDateQuery{ - CreationDate: timestamppb.New(creationDate), + args: args{ + context.Background(), + &session.SearchQuery{ + Query: &session.SearchQuery_CreationDateQuery{ + CreationDateQuery: &session.CreationDateQuery{ + CreationDate: timestamppb.New(creationDate), + }, }, - }, - }}, + }}, want: mustNewTimestampQuery(t, query.SessionColumnCreationDate, creationDate, query.TimestampEquals), }, + { + name: "own creator", + args: args{ + authz.SetCtxData(context.Background(), authz.CtxData{UserID: "creator"}), + &session.SearchQuery{ + Query: &session.SearchQuery_CreatorQuery{ + CreatorQuery: &session.CreatorQuery{}, + }, + }}, + want: mustNewTextQuery(t, query.SessionColumnCreator, "creator", query.TextEquals), + }, + { + name: "empty own creator, error", + args: args{ + authz.SetCtxData(context.Background(), authz.CtxData{UserID: ""}), + &session.SearchQuery{ + Query: &session.SearchQuery_CreatorQuery{ + CreatorQuery: &session.CreatorQuery{}, + }, + }}, + wantErr: zerrors.ThrowInvalidArgument(nil, "GRPC-x8n24uh", "List.Query.Invalid"), + }, + { + name: "creator", + args: args{ + authz.SetCtxData(context.Background(), authz.CtxData{UserID: "creator1"}), + &session.SearchQuery{ + Query: &session.SearchQuery_CreatorQuery{ + CreatorQuery: &session.CreatorQuery{Id: gu.Ptr("creator2")}, + }, + }}, + want: mustNewTextQuery(t, query.SessionColumnCreator, "creator2", query.TextEquals), + }, + { + name: "empty creator, error", + args: args{ + authz.SetCtxData(context.Background(), authz.CtxData{UserID: "creator1"}), + &session.SearchQuery{ + Query: &session.SearchQuery_CreatorQuery{ + CreatorQuery: &session.CreatorQuery{Id: gu.Ptr("")}, + }, + }}, + wantErr: zerrors.ThrowInvalidArgument(nil, "GRPC-x8n24uh", "List.Query.Invalid"), + }, + { + name: "empty own useragent, error", + args: args{ + authz.SetCtxData(context.Background(), authz.CtxData{AgentID: ""}), + &session.SearchQuery{ + Query: &session.SearchQuery_UserAgentQuery{ + UserAgentQuery: &session.UserAgentQuery{}, + }, + }}, + wantErr: zerrors.ThrowInvalidArgument(nil, "GRPC-x8n23uh", "List.Query.Invalid"), + }, + { + name: "own useragent", + args: args{ + authz.SetCtxData(context.Background(), authz.CtxData{AgentID: "agent"}), + &session.SearchQuery{ + Query: &session.SearchQuery_UserAgentQuery{ + UserAgentQuery: &session.UserAgentQuery{}, + }, + }}, + want: mustNewTextQuery(t, query.SessionColumnUserAgentFingerprintID, "agent", query.TextEquals), + }, + { + name: "empty useragent, error", + args: args{ + authz.SetCtxData(context.Background(), authz.CtxData{AgentID: "agent"}), + &session.SearchQuery{ + Query: &session.SearchQuery_UserAgentQuery{ + UserAgentQuery: &session.UserAgentQuery{FingerprintId: gu.Ptr("")}, + }, + }}, + wantErr: zerrors.ThrowInvalidArgument(nil, "GRPC-x8n23uh", "List.Query.Invalid"), + }, + { + name: "useragent", + args: args{ + authz.SetCtxData(context.Background(), authz.CtxData{AgentID: "agent1"}), + &session.SearchQuery{ + Query: &session.SearchQuery_UserAgentQuery{ + UserAgentQuery: &session.UserAgentQuery{FingerprintId: gu.Ptr("agent2")}, + }, + }}, + want: mustNewTextQuery(t, query.SessionColumnUserAgentFingerprintID, "agent2", query.TextEquals), + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := sessionQueryToQuery(tt.args.query) + got, err := sessionQueryToQuery(tt.args.ctx, tt.args.query) require.ErrorIs(t, err, tt.wantErr) assert.Equal(t, tt.want, got) }) diff --git a/internal/api/grpc/session/v2beta/integration_test/query_test.go b/internal/api/grpc/session/v2beta/integration_test/query_test.go new file mode 100644 index 0000000000..b347ba8224 --- /dev/null +++ b/internal/api/grpc/session/v2beta/integration_test/query_test.go @@ -0,0 +1,512 @@ +//go:build integration + +package session_test + +import ( + "context" + "testing" + "time" + + "github.com/golang/protobuf/ptypes/timestamp" + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/durationpb" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/integration" + object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" + session "github.com/zitadel/zitadel/pkg/grpc/session/v2beta" +) + +func TestServer_GetSession(t *testing.T) { + type args struct { + ctx context.Context + req *session.GetSessionRequest + dep func(ctx context.Context, t *testing.T, request *session.GetSessionRequest) uint64 + } + tests := []struct { + name string + args args + want *session.GetSessionResponse + wantFactors []wantFactor + wantExpirationWindow time.Duration + wantErr bool + }{ + { + name: "get session, no id provided", + args: args{ + CTX, + &session.GetSessionRequest{ + SessionId: "", + }, + nil, + }, + wantErr: true, + }, + { + name: "get session, not found", + args: args{ + CTX, + &session.GetSessionRequest{ + SessionId: "unknown", + }, + nil, + }, + wantErr: true, + }, + { + name: "get session, no permission", + args: args{ + UserCTX, + &session.GetSessionRequest{}, + func(ctx context.Context, t *testing.T, request *session.GetSessionRequest) uint64 { + resp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{}) + require.NoError(t, err) + request.SessionId = resp.SessionId + return resp.GetDetails().GetSequence() + }, + }, + wantErr: true, + }, + { + name: "get session, permission, ok", + args: args{ + CTX, + &session.GetSessionRequest{}, + func(ctx context.Context, t *testing.T, request *session.GetSessionRequest) uint64 { + resp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{}) + require.NoError(t, err) + request.SessionId = resp.SessionId + return resp.GetDetails().GetSequence() + }, + }, + want: &session.GetSessionResponse{ + Session: &session.Session{}, + }, + }, + { + name: "get session, token, ok", + args: args{ + UserCTX, + &session.GetSessionRequest{}, + func(ctx context.Context, t *testing.T, request *session.GetSessionRequest) uint64 { + resp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{}) + require.NoError(t, err) + request.SessionId = resp.SessionId + request.SessionToken = gu.Ptr(resp.SessionToken) + return resp.GetDetails().GetSequence() + }, + }, + want: &session.GetSessionResponse{ + Session: &session.Session{}, + }, + }, + { + name: "get session, user agent, ok", + args: args{ + UserCTX, + &session.GetSessionRequest{}, + func(ctx context.Context, t *testing.T, request *session.GetSessionRequest) uint64 { + resp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{ + UserAgent: &session.UserAgent{ + FingerprintId: gu.Ptr("fingerPrintID"), + Ip: gu.Ptr("1.2.3.4"), + Description: gu.Ptr("Description"), + Header: map[string]*session.UserAgent_HeaderValues{ + "foo": {Values: []string{"foo", "bar"}}, + }, + }, + }, + ) + require.NoError(t, err) + request.SessionId = resp.SessionId + request.SessionToken = gu.Ptr(resp.SessionToken) + return resp.GetDetails().GetSequence() + }, + }, + want: &session.GetSessionResponse{ + Session: &session.Session{ + UserAgent: &session.UserAgent{ + FingerprintId: gu.Ptr("fingerPrintID"), + Ip: gu.Ptr("1.2.3.4"), + Description: gu.Ptr("Description"), + Header: map[string]*session.UserAgent_HeaderValues{ + "foo": {Values: []string{"foo", "bar"}}, + }, + }, + }, + }, + }, + { + name: "get session, lifetime, ok", + args: args{ + UserCTX, + &session.GetSessionRequest{}, + func(ctx context.Context, t *testing.T, request *session.GetSessionRequest) uint64 { + resp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{ + Lifetime: durationpb.New(5 * time.Minute), + }, + ) + require.NoError(t, err) + request.SessionId = resp.SessionId + request.SessionToken = gu.Ptr(resp.SessionToken) + return resp.GetDetails().GetSequence() + }, + }, + wantExpirationWindow: 5 * time.Minute, + want: &session.GetSessionResponse{ + Session: &session.Session{}, + }, + }, + { + name: "get session, metadata, ok", + args: args{ + UserCTX, + &session.GetSessionRequest{}, + func(ctx context.Context, t *testing.T, request *session.GetSessionRequest) uint64 { + resp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{ + Metadata: map[string][]byte{"foo": []byte("bar")}, + }, + ) + require.NoError(t, err) + request.SessionId = resp.SessionId + request.SessionToken = gu.Ptr(resp.SessionToken) + return resp.GetDetails().GetSequence() + }, + }, + want: &session.GetSessionResponse{ + Session: &session.Session{ + Metadata: map[string][]byte{"foo": []byte("bar")}, + }, + }, + }, + { + name: "get session, user, ok", + args: args{ + UserCTX, + &session.GetSessionRequest{}, + func(ctx context.Context, t *testing.T, request *session.GetSessionRequest) uint64 { + resp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{ + Checks: &session.Checks{ + User: &session.CheckUser{ + Search: &session.CheckUser_UserId{ + UserId: User.GetUserId(), + }, + }, + }, + }, + ) + require.NoError(t, err) + request.SessionId = resp.SessionId + request.SessionToken = gu.Ptr(resp.SessionToken) + return resp.GetDetails().GetSequence() + }, + }, + wantFactors: []wantFactor{wantUserFactor}, + want: &session.GetSessionResponse{ + Session: &session.Session{}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var sequence uint64 + if tt.args.dep != nil { + sequence = tt.args.dep(tt.args.ctx, t, tt.args.req) + } + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tt.args.ctx, time.Minute) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + got, err := Client.GetSession(tt.args.ctx, tt.args.req) + if tt.wantErr { + assert.Error(ttt, err) + return + } + if !assert.NoError(ttt, err) { + return + } + + tt.want.Session.Id = tt.args.req.SessionId + tt.want.Session.Sequence = sequence + verifySession(ttt, got.GetSession(), tt.want.GetSession(), time.Minute, tt.wantExpirationWindow, User.GetUserId(), tt.wantFactors...) + }, retryDuration, tick) + }) + } +} + +type sessionAttr struct { + ID string + UserID string + UserAgent string + CreationDate *timestamp.Timestamp + ChangeDate *timestamppb.Timestamp + Details *object.Details +} + +type sessionAttrs []*sessionAttr + +func (u sessionAttrs) ids() []string { + ids := make([]string, len(u)) + for i := range u { + ids[i] = u[i].ID + } + return ids +} + +func createSessions(ctx context.Context, t *testing.T, count int, userID string, userAgent string, lifetime *durationpb.Duration, metadata map[string][]byte) sessionAttrs { + infos := make([]*sessionAttr, count) + for i := 0; i < count; i++ { + infos[i] = createSession(ctx, t, userID, userAgent, lifetime, metadata) + } + return infos +} + +func createSession(ctx context.Context, t *testing.T, userID string, userAgent string, lifetime *durationpb.Duration, metadata map[string][]byte) *sessionAttr { + req := &session.CreateSessionRequest{} + if userID != "" { + req.Checks = &session.Checks{ + User: &session.CheckUser{ + Search: &session.CheckUser_UserId{ + UserId: userID, + }, + }, + } + } + if userAgent != "" { + req.UserAgent = &session.UserAgent{ + FingerprintId: gu.Ptr(userAgent), + Ip: gu.Ptr("1.2.3.4"), + Description: gu.Ptr("Description"), + Header: map[string]*session.UserAgent_HeaderValues{ + "foo": {Values: []string{"foo", "bar"}}, + }, + } + } + if lifetime != nil { + req.Lifetime = lifetime + } + if metadata != nil { + req.Metadata = metadata + } + resp, err := Client.CreateSession(ctx, req) + require.NoError(t, err) + return &sessionAttr{ + resp.GetSessionId(), + userID, + userAgent, + resp.GetDetails().GetChangeDate(), + resp.GetDetails().GetChangeDate(), + resp.GetDetails(), + } +} + +func TestServer_ListSessions(t *testing.T) { + type args struct { + ctx context.Context + req *session.ListSessionsRequest + dep func(ctx context.Context, t *testing.T, request *session.ListSessionsRequest) []*sessionAttr + } + tests := []struct { + name string + args args + want *session.ListSessionsResponse + wantFactors []wantFactor + wantExpirationWindow time.Duration + wantErr bool + }{ + { + name: "list sessions, not found", + args: args{ + CTX, + &session.ListSessionsRequest{ + Queries: []*session.SearchQuery{ + {Query: &session.SearchQuery_IdsQuery{IdsQuery: &session.IDsQuery{Ids: []string{"unknown"}}}}, + }, + }, + func(ctx context.Context, t *testing.T, request *session.ListSessionsRequest) []*sessionAttr { + return []*sessionAttr{} + }, + }, + want: &session.ListSessionsResponse{ + Details: &object.ListDetails{ + TotalResult: 0, + Timestamp: timestamppb.Now(), + }, + Sessions: []*session.Session{}, + }, + }, + { + name: "list sessions, wrong creator", + args: args{ + UserCTX, + &session.ListSessionsRequest{}, + func(ctx context.Context, t *testing.T, request *session.ListSessionsRequest) []*sessionAttr { + info := createSession(ctx, t, "", "", nil, nil) + request.Queries = append(request.Queries, &session.SearchQuery{Query: &session.SearchQuery_IdsQuery{IdsQuery: &session.IDsQuery{Ids: []string{info.ID}}}}) + return []*sessionAttr{} + }, + }, + want: &session.ListSessionsResponse{ + Details: &object.ListDetails{ + TotalResult: 0, + Timestamp: timestamppb.Now(), + }, + Sessions: []*session.Session{}, + }, + }, + { + name: "list sessions, full, ok", + args: args{ + CTX, + &session.ListSessionsRequest{}, + func(ctx context.Context, t *testing.T, request *session.ListSessionsRequest) []*sessionAttr { + info := createSession(ctx, t, User.GetUserId(), "agent", durationpb.New(time.Minute*5), map[string][]byte{"key": []byte("value")}) + request.Queries = append(request.Queries, &session.SearchQuery{Query: &session.SearchQuery_IdsQuery{IdsQuery: &session.IDsQuery{Ids: []string{info.ID}}}}) + return []*sessionAttr{info} + }, + }, + wantExpirationWindow: time.Minute * 5, + wantFactors: []wantFactor{wantUserFactor}, + want: &session.ListSessionsResponse{ + Details: &object.ListDetails{ + TotalResult: 1, + Timestamp: timestamppb.Now(), + }, + Sessions: []*session.Session{ + { + Metadata: map[string][]byte{"key": []byte("value")}, + UserAgent: &session.UserAgent{ + FingerprintId: gu.Ptr("agent"), + Ip: gu.Ptr("1.2.3.4"), + Description: gu.Ptr("Description"), + Header: map[string]*session.UserAgent_HeaderValues{ + "foo": {Values: []string{"foo", "bar"}}, + }, + }, + }, + }, + }, + }, + { + name: "list sessions, multiple, ok", + args: args{ + CTX, + &session.ListSessionsRequest{}, + func(ctx context.Context, t *testing.T, request *session.ListSessionsRequest) []*sessionAttr { + infos := createSessions(ctx, t, 3, User.GetUserId(), "agent", durationpb.New(time.Minute*5), map[string][]byte{"key": []byte("value")}) + request.Queries = append(request.Queries, &session.SearchQuery{Query: &session.SearchQuery_IdsQuery{IdsQuery: &session.IDsQuery{Ids: infos.ids()}}}) + return infos + }, + }, + wantExpirationWindow: time.Minute * 5, + wantFactors: []wantFactor{wantUserFactor}, + want: &session.ListSessionsResponse{ + Details: &object.ListDetails{ + TotalResult: 3, + Timestamp: timestamppb.Now(), + }, + Sessions: []*session.Session{ + { + Metadata: map[string][]byte{"key": []byte("value")}, + UserAgent: &session.UserAgent{ + FingerprintId: gu.Ptr("agent"), + Ip: gu.Ptr("1.2.3.4"), + Description: gu.Ptr("Description"), + Header: map[string]*session.UserAgent_HeaderValues{ + "foo": {Values: []string{"foo", "bar"}}, + }, + }, + }, + { + Metadata: map[string][]byte{"key": []byte("value")}, + UserAgent: &session.UserAgent{ + FingerprintId: gu.Ptr("agent"), + Ip: gu.Ptr("1.2.3.4"), + Description: gu.Ptr("Description"), + Header: map[string]*session.UserAgent_HeaderValues{ + "foo": {Values: []string{"foo", "bar"}}, + }, + }, + }, + { + Metadata: map[string][]byte{"key": []byte("value")}, + UserAgent: &session.UserAgent{ + FingerprintId: gu.Ptr("agent"), + Ip: gu.Ptr("1.2.3.4"), + Description: gu.Ptr("Description"), + Header: map[string]*session.UserAgent_HeaderValues{ + "foo": {Values: []string{"foo", "bar"}}, + }, + }, + }, + }, + }, + }, + { + name: "list sessions, userid, ok", + args: args{ + CTX, + &session.ListSessionsRequest{}, + func(ctx context.Context, t *testing.T, request *session.ListSessionsRequest) []*sessionAttr { + createdUser := createFullUser(ctx) + info := createSession(ctx, t, createdUser.GetUserId(), "agent", durationpb.New(time.Minute*5), map[string][]byte{"key": []byte("value")}) + request.Queries = append(request.Queries, &session.SearchQuery{Query: &session.SearchQuery_UserIdQuery{UserIdQuery: &session.UserIDQuery{Id: createdUser.GetUserId()}}}) + return []*sessionAttr{info} + }, + }, + wantExpirationWindow: time.Minute * 5, + wantFactors: []wantFactor{wantUserFactor}, + want: &session.ListSessionsResponse{ + Details: &object.ListDetails{ + TotalResult: 1, + Timestamp: timestamppb.Now(), + }, + Sessions: []*session.Session{ + { + Metadata: map[string][]byte{"key": []byte("value")}, + UserAgent: &session.UserAgent{ + FingerprintId: gu.Ptr("agent"), + Ip: gu.Ptr("1.2.3.4"), + Description: gu.Ptr("Description"), + Header: map[string]*session.UserAgent_HeaderValues{ + "foo": {Values: []string{"foo", "bar"}}, + }, + }, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + infos := tt.args.dep(CTX, t, tt.args.req) + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tt.args.ctx, time.Minute) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + got, err := Client.ListSessions(tt.args.ctx, tt.args.req) + if tt.wantErr { + assert.Error(ttt, err) + return + } + if !assert.NoError(ttt, err) { + return + } + + if !assert.Equal(ttt, got.Details.TotalResult, tt.want.Details.TotalResult) || !assert.Len(ttt, got.Sessions, len(tt.want.Sessions)) { + return + } + + for i := range infos { + tt.want.Sessions[i].Id = infos[i].ID + tt.want.Sessions[i].Sequence = infos[i].Details.GetSequence() + tt.want.Sessions[i].CreationDate = infos[i].Details.GetChangeDate() + tt.want.Sessions[i].ChangeDate = infos[i].Details.GetChangeDate() + + verifySession(ttt, got.Sessions[i], tt.want.Sessions[i], time.Minute, tt.wantExpirationWindow, infos[i].UserID, tt.wantFactors...) + } + integration.AssertListDetails(ttt, tt.want, got) + }, retryDuration, tick) + }) + } +} diff --git a/internal/api/grpc/session/v2beta/integration_test/server_test.go b/internal/api/grpc/session/v2beta/integration_test/server_test.go new file mode 100644 index 0000000000..4920e6ec35 --- /dev/null +++ b/internal/api/grpc/session/v2beta/integration_test/server_test.go @@ -0,0 +1,74 @@ +//go:build integration + +package session_test + +import ( + "context" + "os" + "testing" + "time" + + "github.com/zitadel/logging" + + "github.com/zitadel/zitadel/internal/integration" + session "github.com/zitadel/zitadel/pkg/grpc/session/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" +) + +var ( + CTX context.Context + IAMOwnerCTX context.Context + UserCTX context.Context + Instance *integration.Instance + Client session.SessionServiceClient + User *user.AddHumanUserResponse + DeactivatedUser *user.AddHumanUserResponse + LockedUser *user.AddHumanUserResponse +) + +func TestMain(m *testing.M) { + os.Exit(func() int { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + Instance = integration.NewInstance(ctx) + Client = Instance.Client.SessionV2beta + + CTX = Instance.WithAuthorization(ctx, integration.UserTypeOrgOwner) + IAMOwnerCTX = Instance.WithAuthorization(ctx, integration.UserTypeIAMOwner) + UserCTX = Instance.WithAuthorization(ctx, integration.UserTypeNoPermission) + User = createFullUser(CTX) + DeactivatedUser = createDeactivatedUser(CTX) + LockedUser = createLockedUser(CTX) + return m.Run() + }()) +} + +func createFullUser(ctx context.Context) *user.AddHumanUserResponse { + userResp := Instance.CreateHumanUser(ctx) + Instance.Client.UserV2.VerifyEmail(ctx, &user.VerifyEmailRequest{ + UserId: userResp.GetUserId(), + VerificationCode: userResp.GetEmailCode(), + }) + Instance.Client.UserV2.VerifyPhone(ctx, &user.VerifyPhoneRequest{ + UserId: userResp.GetUserId(), + VerificationCode: userResp.GetPhoneCode(), + }) + Instance.SetUserPassword(ctx, userResp.GetUserId(), integration.UserPassword, false) + Instance.RegisterUserPasskey(ctx, userResp.GetUserId()) + return userResp +} + +func createDeactivatedUser(ctx context.Context) *user.AddHumanUserResponse { + userResp := Instance.CreateHumanUser(ctx) + _, err := Instance.Client.UserV2.DeactivateUser(ctx, &user.DeactivateUserRequest{UserId: userResp.GetUserId()}) + logging.OnError(err).Fatal("deactivate human user") + return userResp +} + +func createLockedUser(ctx context.Context) *user.AddHumanUserResponse { + userResp := Instance.CreateHumanUser(ctx) + _, err := Instance.Client.UserV2.LockUser(ctx, &user.LockUserRequest{UserId: userResp.GetUserId()}) + logging.OnError(err).Fatal("lock human user") + return userResp +} diff --git a/internal/api/grpc/session/v2beta/integration_test/session_test.go b/internal/api/grpc/session/v2beta/integration_test/session_test.go index 52e355204d..26d2291629 100644 --- a/internal/api/grpc/session/v2beta/integration_test/session_test.go +++ b/internal/api/grpc/session/v2beta/integration_test/session_test.go @@ -5,7 +5,6 @@ package session_test import ( "context" "fmt" - "os" "testing" "time" @@ -14,7 +13,6 @@ import ( "github.com/pquerna/otp/totp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/zitadel/logging" "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" @@ -29,62 +27,6 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) -var ( - CTX context.Context - IAMOwnerCTX context.Context - Instance *integration.Instance - Client session.SessionServiceClient - User *user.AddHumanUserResponse - DeactivatedUser *user.AddHumanUserResponse - LockedUser *user.AddHumanUserResponse -) - -func TestMain(m *testing.M) { - os.Exit(func() int { - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) - defer cancel() - - Instance = integration.NewInstance(ctx) - Client = Instance.Client.SessionV2beta - - CTX = Instance.WithAuthorization(ctx, integration.UserTypeOrgOwner) - IAMOwnerCTX = Instance.WithAuthorization(ctx, integration.UserTypeIAMOwner) - User = createFullUser(CTX) - DeactivatedUser = createDeactivatedUser(CTX) - LockedUser = createLockedUser(CTX) - return m.Run() - }()) -} - -func createFullUser(ctx context.Context) *user.AddHumanUserResponse { - userResp := Instance.CreateHumanUser(ctx) - Instance.Client.UserV2.VerifyEmail(ctx, &user.VerifyEmailRequest{ - UserId: userResp.GetUserId(), - VerificationCode: userResp.GetEmailCode(), - }) - Instance.Client.UserV2.VerifyPhone(ctx, &user.VerifyPhoneRequest{ - UserId: userResp.GetUserId(), - VerificationCode: userResp.GetPhoneCode(), - }) - Instance.SetUserPassword(ctx, userResp.GetUserId(), integration.UserPassword, false) - Instance.RegisterUserPasskey(ctx, userResp.GetUserId()) - return userResp -} - -func createDeactivatedUser(ctx context.Context) *user.AddHumanUserResponse { - userResp := Instance.CreateHumanUser(ctx) - _, err := Instance.Client.UserV2.DeactivateUser(ctx, &user.DeactivateUserRequest{UserId: userResp.GetUserId()}) - logging.OnError(err).Fatal("deactivate human user") - return userResp -} - -func createLockedUser(ctx context.Context) *user.AddHumanUserResponse { - userResp := Instance.CreateHumanUser(ctx) - _, err := Instance.Client.UserV2.LockUser(ctx, &user.LockUserRequest{UserId: userResp.GetUserId()}) - logging.OnError(err).Fatal("lock human user") - return userResp -} - func verifyCurrentSession(t testing.TB, id, token string, sequence uint64, window time.Duration, metadata map[string][]byte, userAgent *session.UserAgent, expirationWindow time.Duration, userID string, factors ...wantFactor) *session.Session { t.Helper() require.NotEmpty(t, id) @@ -96,15 +38,25 @@ func verifyCurrentSession(t testing.TB, id, token string, sequence uint64, windo }) require.NoError(t, err) s := resp.GetSession() + want := &session.Session{ + Id: id, + Sequence: sequence, + Metadata: metadata, + UserAgent: userAgent, + } + verifySession(t, s, want, window, expirationWindow, userID, factors...) + return s +} - assert.Equal(t, id, s.GetId()) +func verifySession(t assert.TestingT, s *session.Session, want *session.Session, window time.Duration, expirationWindow time.Duration, userID string, factors ...wantFactor) { + assert.Equal(t, want.Id, s.GetId()) assert.WithinRange(t, s.GetCreationDate().AsTime(), time.Now().Add(-window), time.Now().Add(window)) assert.WithinRange(t, s.GetChangeDate().AsTime(), time.Now().Add(-window), time.Now().Add(window)) - assert.Equal(t, sequence, s.GetSequence()) - assert.Equal(t, metadata, s.GetMetadata()) + assert.Equal(t, want.Sequence, s.GetSequence()) + assert.Equal(t, want.Metadata, s.GetMetadata()) - if !proto.Equal(userAgent, s.GetUserAgent()) { - t.Errorf("user agent =\n%v\nwant\n%v", s.GetUserAgent(), userAgent) + if !proto.Equal(want.UserAgent, s.GetUserAgent()) { + t.Errorf("user agent =\n%v\nwant\n%v", s.GetUserAgent(), want.UserAgent) } if expirationWindow == 0 { assert.Nil(t, s.GetExpirationDate()) @@ -113,7 +65,6 @@ func verifyCurrentSession(t testing.TB, id, token string, sequence uint64, windo } verifyFactors(t, s.GetFactors(), window, userID, factors) - return s } type wantFactor int @@ -129,7 +80,7 @@ const ( wantOTPEmailFactor ) -func verifyFactors(t testing.TB, factors *session.Factors, window time.Duration, userID string, want []wantFactor) { +func verifyFactors(t assert.TestingT, factors *session.Factors, window time.Duration, userID string, want []wantFactor) { for _, w := range want { switch w { case wantUserFactor: @@ -194,8 +145,15 @@ func TestServer_CreateSession(t *testing.T) { }, }, { - name: "user agent", + name: "full session", req: &session.CreateSessionRequest{ + Checks: &session.Checks{ + User: &session.CheckUser{ + Search: &session.CheckUser_UserId{ + UserId: User.GetUserId(), + }, + }, + }, Metadata: map[string][]byte{"foo": []byte("bar")}, UserAgent: &session.UserAgent{ FingerprintId: gu.Ptr("fingerPrintID"), @@ -205,6 +163,7 @@ func TestServer_CreateSession(t *testing.T) { "foo": {Values: []string{"foo", "bar"}}, }, }, + Lifetime: durationpb.New(5 * time.Minute), }, want: &session.CreateSessionResponse{ Details: &object.Details{ @@ -212,14 +171,6 @@ func TestServer_CreateSession(t *testing.T) { ResourceOwner: Instance.ID(), }, }, - wantUserAgent: &session.UserAgent{ - FingerprintId: gu.Ptr("fingerPrintID"), - Ip: gu.Ptr("1.2.3.4"), - Description: gu.Ptr("Description"), - Header: map[string]*session.UserAgent_HeaderValues{ - "foo": {Values: []string{"foo", "bar"}}, - }, - }, }, { name: "negative lifetime", @@ -229,40 +180,6 @@ func TestServer_CreateSession(t *testing.T) { }, wantErr: true, }, - { - name: "lifetime", - req: &session.CreateSessionRequest{ - Metadata: map[string][]byte{"foo": []byte("bar")}, - Lifetime: durationpb.New(5 * time.Minute), - }, - want: &session.CreateSessionResponse{ - Details: &object.Details{ - ChangeDate: timestamppb.Now(), - ResourceOwner: Instance.ID(), - }, - }, - wantExpirationWindow: 5 * time.Minute, - }, - { - name: "with user", - req: &session.CreateSessionRequest{ - Checks: &session.Checks{ - User: &session.CheckUser{ - Search: &session.CheckUser_UserId{ - UserId: User.GetUserId(), - }, - }, - }, - Metadata: map[string][]byte{"foo": []byte("bar")}, - }, - want: &session.CreateSessionResponse{ - Details: &object.Details{ - ChangeDate: timestamppb.Now(), - ResourceOwner: Instance.ID(), - }, - }, - wantFactors: []wantFactor{wantUserFactor}, - }, { name: "deactivated user", req: &session.CreateSessionRequest{ @@ -340,8 +257,6 @@ func TestServer_CreateSession(t *testing.T) { } require.NoError(t, err) integration.AssertDetails(t, tt.want, got) - - verifyCurrentSession(t, got.GetSessionId(), got.GetSessionToken(), got.GetDetails().GetSequence(), time.Minute, tt.req.GetMetadata(), tt.wantUserAgent, tt.wantExpirationWindow, User.GetUserId(), tt.wantFactors...) }) } } @@ -946,21 +861,30 @@ func Test_ZITADEL_API_missing_authentication(t *testing.T) { require.NoError(t, err) ctx := metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("Bearer %s", createResp.GetSessionToken())) - sessionResp, err := Client.GetSession(ctx, &session.GetSessionRequest{SessionId: createResp.GetSessionId()}) - require.Error(t, err) - require.Nil(t, sessionResp) + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(ctx, time.Minute) + require.EventuallyWithT(t, func(tt *assert.CollectT) { + sessionResp, err := Client.GetSession(ctx, &session.GetSessionRequest{SessionId: createResp.GetSessionId()}) + if !assert.Error(tt, err) { + return + } + assert.Nil(tt, sessionResp) + }, retryDuration, tick) } func Test_ZITADEL_API_success(t *testing.T) { id, token, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, User.GetUserId()) - ctx := integration.WithAuthorizationToken(context.Background(), token) - sessionResp, err := Client.GetSession(ctx, &session.GetSessionRequest{SessionId: id}) - require.NoError(t, err) - webAuthN := sessionResp.GetSession().GetFactors().GetWebAuthN() - require.NotNil(t, id, webAuthN.GetVerifiedAt().AsTime()) - require.True(t, webAuthN.GetUserVerified()) + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(ctx, time.Minute) + require.EventuallyWithT(t, func(tt *assert.CollectT) { + sessionResp, err := Client.GetSession(ctx, &session.GetSessionRequest{SessionId: id}) + if !assert.NoError(tt, err) { + return + } + webAuthN := sessionResp.GetSession().GetFactors().GetWebAuthN() + assert.NotNil(tt, id, webAuthN.GetVerifiedAt().AsTime()) + assert.True(tt, webAuthN.GetUserVerified()) + }, retryDuration, tick) } func Test_ZITADEL_API_session_not_found(t *testing.T) { @@ -968,18 +892,30 @@ func Test_ZITADEL_API_session_not_found(t *testing.T) { // test session token works ctx := integration.WithAuthorizationToken(context.Background(), token) - _, err := Client.GetSession(ctx, &session.GetSessionRequest{SessionId: id}) - require.NoError(t, err) + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(ctx, time.Minute) + require.EventuallyWithT(t, func(tt *assert.CollectT) { + _, err := Client.GetSession(ctx, &session.GetSessionRequest{SessionId: id}) + if !assert.NoError(tt, err) { + return + } + }, retryDuration, tick) //terminate the session and test it does not work anymore - _, err = Client.DeleteSession(CTX, &session.DeleteSessionRequest{ + _, err := Client.DeleteSession(CTX, &session.DeleteSessionRequest{ SessionId: id, SessionToken: gu.Ptr(token), }) require.NoError(t, err) + ctx = integration.WithAuthorizationToken(context.Background(), token) - _, err = Client.GetSession(ctx, &session.GetSessionRequest{SessionId: id}) - require.Error(t, err) + retryDuration, tick = integration.WaitForAndTickWithMaxDuration(ctx, time.Minute) + require.EventuallyWithT(t, func(tt *assert.CollectT) { + _, err := Client.GetSession(ctx, &session.GetSessionRequest{SessionId: id}) + if !assert.Error(tt, err) { + return + } + }, retryDuration, tick) } func Test_ZITADEL_API_session_expired(t *testing.T) { @@ -987,8 +923,13 @@ func Test_ZITADEL_API_session_expired(t *testing.T) { // test session token works ctx := integration.WithAuthorizationToken(context.Background(), token) - _, err := Client.GetSession(ctx, &session.GetSessionRequest{SessionId: id}) - require.NoError(t, err) + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(ctx, time.Minute) + require.EventuallyWithT(t, func(tt *assert.CollectT) { + _, err := Client.GetSession(ctx, &session.GetSessionRequest{SessionId: id}) + if !assert.NoError(tt, err) { + return + } + }, retryDuration, tick) // ensure session expires and does not work anymore time.Sleep(20 * time.Second) diff --git a/internal/api/grpc/session/v2beta/server.go b/internal/api/grpc/session/v2beta/server.go index 550d013ad5..cf0d0c27f0 100644 --- a/internal/api/grpc/session/v2beta/server.go +++ b/internal/api/grpc/session/v2beta/server.go @@ -6,6 +6,7 @@ import ( "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/grpc/server" "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" session "github.com/zitadel/zitadel/pkg/grpc/session/v2beta" ) @@ -16,6 +17,8 @@ type Server struct { session.UnimplementedSessionServiceServer command *command.Commands query *query.Queries + + checkPermission domain.PermissionCheck } type Config struct{} @@ -23,10 +26,12 @@ type Config struct{} func CreateServer( command *command.Commands, query *query.Queries, + checkPermission domain.PermissionCheck, ) *Server { return &Server{ - command: command, - query: query, + command: command, + query: query, + checkPermission: checkPermission, } } diff --git a/internal/api/grpc/session/v2beta/session.go b/internal/api/grpc/session/v2beta/session.go index 7e67a4b3ff..3b36b8ba83 100644 --- a/internal/api/grpc/session/v2beta/session.go +++ b/internal/api/grpc/session/v2beta/session.go @@ -32,7 +32,7 @@ var ( ) func (s *Server) GetSession(ctx context.Context, req *session.GetSessionRequest) (*session.GetSessionResponse, error) { - res, err := s.query.SessionByID(ctx, true, req.GetSessionId(), req.GetSessionToken()) + res, err := s.query.SessionByID(ctx, true, req.GetSessionId(), req.GetSessionToken(), s.checkPermission) if err != nil { return nil, err } @@ -46,7 +46,7 @@ func (s *Server) ListSessions(ctx context.Context, req *session.ListSessionsRequ if err != nil { return nil, err } - sessions, err := s.query.SearchSessions(ctx, queries) + sessions, err := s.query.SearchSessions(ctx, queries, s.checkPermission) if err != nil { return nil, err } diff --git a/internal/authz/repository/eventsourcing/eventstore/token_verifier.go b/internal/authz/repository/eventsourcing/eventstore/token_verifier.go index 9dec3fcf00..b707631c22 100644 --- a/internal/authz/repository/eventsourcing/eventstore/token_verifier.go +++ b/internal/authz/repository/eventsourcing/eventstore/token_verifier.go @@ -159,7 +159,7 @@ func (repo *TokenVerifierRepo) verifySessionToken(ctx context.Context, sessionID ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - session, err := repo.Query.SessionByID(ctx, true, sessionID, token) + session, err := repo.Query.SessionByID(ctx, true, sessionID, token, nil) if err != nil { return "", "", "", err } diff --git a/internal/notification/handlers/mock/commands.mock.go b/internal/notification/handlers/mock/commands.mock.go index ee6eb3c6b1..de32ce067c 100644 --- a/internal/notification/handlers/mock/commands.mock.go +++ b/internal/notification/handlers/mock/commands.mock.go @@ -25,7 +25,6 @@ import ( type MockCommands struct { ctrl *gomock.Controller recorder *MockCommandsMockRecorder - isgomock struct{} } // MockCommandsMockRecorder is the mock recorder for MockCommands. @@ -46,253 +45,253 @@ func (m *MockCommands) EXPECT() *MockCommandsMockRecorder { } // HumanEmailVerificationCodeSent mocks base method. -func (m *MockCommands) HumanEmailVerificationCodeSent(ctx context.Context, orgID, userID string) error { +func (m *MockCommands) HumanEmailVerificationCodeSent(arg0 context.Context, arg1, arg2 string) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "HumanEmailVerificationCodeSent", ctx, orgID, userID) + ret := m.ctrl.Call(m, "HumanEmailVerificationCodeSent", arg0, arg1, arg2) ret0, _ := ret[0].(error) return ret0 } // HumanEmailVerificationCodeSent indicates an expected call of HumanEmailVerificationCodeSent. -func (mr *MockCommandsMockRecorder) HumanEmailVerificationCodeSent(ctx, orgID, userID any) *gomock.Call { +func (mr *MockCommandsMockRecorder) HumanEmailVerificationCodeSent(arg0, arg1, arg2 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanEmailVerificationCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanEmailVerificationCodeSent), ctx, orgID, userID) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanEmailVerificationCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanEmailVerificationCodeSent), arg0, arg1, arg2) } // HumanInitCodeSent mocks base method. -func (m *MockCommands) HumanInitCodeSent(ctx context.Context, orgID, userID string) error { +func (m *MockCommands) HumanInitCodeSent(arg0 context.Context, arg1, arg2 string) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "HumanInitCodeSent", ctx, orgID, userID) + ret := m.ctrl.Call(m, "HumanInitCodeSent", arg0, arg1, arg2) ret0, _ := ret[0].(error) return ret0 } // HumanInitCodeSent indicates an expected call of HumanInitCodeSent. -func (mr *MockCommandsMockRecorder) HumanInitCodeSent(ctx, orgID, userID any) *gomock.Call { +func (mr *MockCommandsMockRecorder) HumanInitCodeSent(arg0, arg1, arg2 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanInitCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanInitCodeSent), ctx, orgID, userID) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanInitCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanInitCodeSent), arg0, arg1, arg2) } // HumanOTPEmailCodeSent mocks base method. -func (m *MockCommands) HumanOTPEmailCodeSent(ctx context.Context, userID, resourceOwner string) error { +func (m *MockCommands) HumanOTPEmailCodeSent(arg0 context.Context, arg1, arg2 string) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "HumanOTPEmailCodeSent", ctx, userID, resourceOwner) + ret := m.ctrl.Call(m, "HumanOTPEmailCodeSent", arg0, arg1, arg2) ret0, _ := ret[0].(error) return ret0 } // HumanOTPEmailCodeSent indicates an expected call of HumanOTPEmailCodeSent. -func (mr *MockCommandsMockRecorder) HumanOTPEmailCodeSent(ctx, userID, resourceOwner any) *gomock.Call { +func (mr *MockCommandsMockRecorder) HumanOTPEmailCodeSent(arg0, arg1, arg2 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanOTPEmailCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanOTPEmailCodeSent), ctx, userID, resourceOwner) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanOTPEmailCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanOTPEmailCodeSent), arg0, arg1, arg2) } // HumanOTPSMSCodeSent mocks base method. -func (m *MockCommands) HumanOTPSMSCodeSent(ctx context.Context, userID, resourceOwner string, generatorInfo *senders.CodeGeneratorInfo) error { +func (m *MockCommands) HumanOTPSMSCodeSent(arg0 context.Context, arg1, arg2 string, arg3 *senders.CodeGeneratorInfo) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "HumanOTPSMSCodeSent", ctx, userID, resourceOwner, generatorInfo) + ret := m.ctrl.Call(m, "HumanOTPSMSCodeSent", arg0, arg1, arg2, arg3) ret0, _ := ret[0].(error) return ret0 } // HumanOTPSMSCodeSent indicates an expected call of HumanOTPSMSCodeSent. -func (mr *MockCommandsMockRecorder) HumanOTPSMSCodeSent(ctx, userID, resourceOwner, generatorInfo any) *gomock.Call { +func (mr *MockCommandsMockRecorder) HumanOTPSMSCodeSent(arg0, arg1, arg2, arg3 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanOTPSMSCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanOTPSMSCodeSent), ctx, userID, resourceOwner, generatorInfo) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanOTPSMSCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanOTPSMSCodeSent), arg0, arg1, arg2, arg3) } // HumanPasswordlessInitCodeSent mocks base method. -func (m *MockCommands) HumanPasswordlessInitCodeSent(ctx context.Context, userID, resourceOwner, codeID string) error { +func (m *MockCommands) HumanPasswordlessInitCodeSent(arg0 context.Context, arg1, arg2, arg3 string) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "HumanPasswordlessInitCodeSent", ctx, userID, resourceOwner, codeID) + ret := m.ctrl.Call(m, "HumanPasswordlessInitCodeSent", arg0, arg1, arg2, arg3) ret0, _ := ret[0].(error) return ret0 } // HumanPasswordlessInitCodeSent indicates an expected call of HumanPasswordlessInitCodeSent. -func (mr *MockCommandsMockRecorder) HumanPasswordlessInitCodeSent(ctx, userID, resourceOwner, codeID any) *gomock.Call { +func (mr *MockCommandsMockRecorder) HumanPasswordlessInitCodeSent(arg0, arg1, arg2, arg3 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanPasswordlessInitCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanPasswordlessInitCodeSent), ctx, userID, resourceOwner, codeID) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanPasswordlessInitCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanPasswordlessInitCodeSent), arg0, arg1, arg2, arg3) } // HumanPhoneVerificationCodeSent mocks base method. -func (m *MockCommands) HumanPhoneVerificationCodeSent(ctx context.Context, orgID, userID string, generatorInfo *senders.CodeGeneratorInfo) error { +func (m *MockCommands) HumanPhoneVerificationCodeSent(arg0 context.Context, arg1, arg2 string, arg3 *senders.CodeGeneratorInfo) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "HumanPhoneVerificationCodeSent", ctx, orgID, userID, generatorInfo) + ret := m.ctrl.Call(m, "HumanPhoneVerificationCodeSent", arg0, arg1, arg2, arg3) ret0, _ := ret[0].(error) return ret0 } // HumanPhoneVerificationCodeSent indicates an expected call of HumanPhoneVerificationCodeSent. -func (mr *MockCommandsMockRecorder) HumanPhoneVerificationCodeSent(ctx, orgID, userID, generatorInfo any) *gomock.Call { +func (mr *MockCommandsMockRecorder) HumanPhoneVerificationCodeSent(arg0, arg1, arg2, arg3 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanPhoneVerificationCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanPhoneVerificationCodeSent), ctx, orgID, userID, generatorInfo) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanPhoneVerificationCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanPhoneVerificationCodeSent), arg0, arg1, arg2, arg3) } // InviteCodeSent mocks base method. -func (m *MockCommands) InviteCodeSent(ctx context.Context, orgID, userID string) error { +func (m *MockCommands) InviteCodeSent(arg0 context.Context, arg1, arg2 string) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "InviteCodeSent", ctx, orgID, userID) + ret := m.ctrl.Call(m, "InviteCodeSent", arg0, arg1, arg2) ret0, _ := ret[0].(error) return ret0 } // InviteCodeSent indicates an expected call of InviteCodeSent. -func (mr *MockCommandsMockRecorder) InviteCodeSent(ctx, orgID, userID any) *gomock.Call { +func (mr *MockCommandsMockRecorder) InviteCodeSent(arg0, arg1, arg2 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InviteCodeSent", reflect.TypeOf((*MockCommands)(nil).InviteCodeSent), ctx, orgID, userID) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InviteCodeSent", reflect.TypeOf((*MockCommands)(nil).InviteCodeSent), arg0, arg1, arg2) } // MilestonePushed mocks base method. -func (m *MockCommands) MilestonePushed(ctx context.Context, instanceID string, msType milestone.Type, endpoints []string) error { +func (m *MockCommands) MilestonePushed(arg0 context.Context, arg1 string, arg2 milestone.Type, arg3 []string) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "MilestonePushed", ctx, instanceID, msType, endpoints) + ret := m.ctrl.Call(m, "MilestonePushed", arg0, arg1, arg2, arg3) ret0, _ := ret[0].(error) return ret0 } // MilestonePushed indicates an expected call of MilestonePushed. -func (mr *MockCommandsMockRecorder) MilestonePushed(ctx, instanceID, msType, endpoints any) *gomock.Call { +func (mr *MockCommandsMockRecorder) MilestonePushed(arg0, arg1, arg2, arg3 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MilestonePushed", reflect.TypeOf((*MockCommands)(nil).MilestonePushed), ctx, instanceID, msType, endpoints) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MilestonePushed", reflect.TypeOf((*MockCommands)(nil).MilestonePushed), arg0, arg1, arg2, arg3) } // NotificationCanceled mocks base method. -func (m *MockCommands) NotificationCanceled(ctx context.Context, tx *sql.Tx, id, resourceOwner string, err error) error { +func (m *MockCommands) NotificationCanceled(arg0 context.Context, arg1 *sql.Tx, arg2, arg3 string, arg4 error) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "NotificationCanceled", ctx, tx, id, resourceOwner, err) + ret := m.ctrl.Call(m, "NotificationCanceled", arg0, arg1, arg2, arg3, arg4) ret0, _ := ret[0].(error) return ret0 } // NotificationCanceled indicates an expected call of NotificationCanceled. -func (mr *MockCommandsMockRecorder) NotificationCanceled(ctx, tx, id, resourceOwner, err any) *gomock.Call { +func (mr *MockCommandsMockRecorder) NotificationCanceled(arg0, arg1, arg2, arg3, arg4 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NotificationCanceled", reflect.TypeOf((*MockCommands)(nil).NotificationCanceled), ctx, tx, id, resourceOwner, err) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NotificationCanceled", reflect.TypeOf((*MockCommands)(nil).NotificationCanceled), arg0, arg1, arg2, arg3, arg4) } // NotificationRetryRequested mocks base method. -func (m *MockCommands) NotificationRetryRequested(ctx context.Context, tx *sql.Tx, id, resourceOwner string, request *command.NotificationRetryRequest, err error) error { +func (m *MockCommands) NotificationRetryRequested(arg0 context.Context, arg1 *sql.Tx, arg2, arg3 string, arg4 *command.NotificationRetryRequest, arg5 error) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "NotificationRetryRequested", ctx, tx, id, resourceOwner, request, err) + ret := m.ctrl.Call(m, "NotificationRetryRequested", arg0, arg1, arg2, arg3, arg4, arg5) ret0, _ := ret[0].(error) return ret0 } // NotificationRetryRequested indicates an expected call of NotificationRetryRequested. -func (mr *MockCommandsMockRecorder) NotificationRetryRequested(ctx, tx, id, resourceOwner, request, err any) *gomock.Call { +func (mr *MockCommandsMockRecorder) NotificationRetryRequested(arg0, arg1, arg2, arg3, arg4, arg5 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NotificationRetryRequested", reflect.TypeOf((*MockCommands)(nil).NotificationRetryRequested), ctx, tx, id, resourceOwner, request, err) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NotificationRetryRequested", reflect.TypeOf((*MockCommands)(nil).NotificationRetryRequested), arg0, arg1, arg2, arg3, arg4, arg5) } // NotificationSent mocks base method. -func (m *MockCommands) NotificationSent(ctx context.Context, tx *sql.Tx, id, instanceID string) error { +func (m *MockCommands) NotificationSent(arg0 context.Context, arg1 *sql.Tx, arg2, arg3 string) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "NotificationSent", ctx, tx, id, instanceID) + ret := m.ctrl.Call(m, "NotificationSent", arg0, arg1, arg2, arg3) ret0, _ := ret[0].(error) return ret0 } // NotificationSent indicates an expected call of NotificationSent. -func (mr *MockCommandsMockRecorder) NotificationSent(ctx, tx, id, instanceID any) *gomock.Call { +func (mr *MockCommandsMockRecorder) NotificationSent(arg0, arg1, arg2, arg3 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NotificationSent", reflect.TypeOf((*MockCommands)(nil).NotificationSent), ctx, tx, id, instanceID) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NotificationSent", reflect.TypeOf((*MockCommands)(nil).NotificationSent), arg0, arg1, arg2, arg3) } // OTPEmailSent mocks base method. -func (m *MockCommands) OTPEmailSent(ctx context.Context, sessionID, resourceOwner string) error { +func (m *MockCommands) OTPEmailSent(arg0 context.Context, arg1, arg2 string) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "OTPEmailSent", ctx, sessionID, resourceOwner) + ret := m.ctrl.Call(m, "OTPEmailSent", arg0, arg1, arg2) ret0, _ := ret[0].(error) return ret0 } // OTPEmailSent indicates an expected call of OTPEmailSent. -func (mr *MockCommandsMockRecorder) OTPEmailSent(ctx, sessionID, resourceOwner any) *gomock.Call { +func (mr *MockCommandsMockRecorder) OTPEmailSent(arg0, arg1, arg2 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OTPEmailSent", reflect.TypeOf((*MockCommands)(nil).OTPEmailSent), ctx, sessionID, resourceOwner) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OTPEmailSent", reflect.TypeOf((*MockCommands)(nil).OTPEmailSent), arg0, arg1, arg2) } // OTPSMSSent mocks base method. -func (m *MockCommands) OTPSMSSent(ctx context.Context, sessionID, resourceOwner string, generatorInfo *senders.CodeGeneratorInfo) error { +func (m *MockCommands) OTPSMSSent(arg0 context.Context, arg1, arg2 string, arg3 *senders.CodeGeneratorInfo) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "OTPSMSSent", ctx, sessionID, resourceOwner, generatorInfo) + ret := m.ctrl.Call(m, "OTPSMSSent", arg0, arg1, arg2, arg3) ret0, _ := ret[0].(error) return ret0 } // OTPSMSSent indicates an expected call of OTPSMSSent. -func (mr *MockCommandsMockRecorder) OTPSMSSent(ctx, sessionID, resourceOwner, generatorInfo any) *gomock.Call { +func (mr *MockCommandsMockRecorder) OTPSMSSent(arg0, arg1, arg2, arg3 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OTPSMSSent", reflect.TypeOf((*MockCommands)(nil).OTPSMSSent), ctx, sessionID, resourceOwner, generatorInfo) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OTPSMSSent", reflect.TypeOf((*MockCommands)(nil).OTPSMSSent), arg0, arg1, arg2, arg3) } // PasswordChangeSent mocks base method. -func (m *MockCommands) PasswordChangeSent(ctx context.Context, orgID, userID string) error { +func (m *MockCommands) PasswordChangeSent(arg0 context.Context, arg1, arg2 string) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "PasswordChangeSent", ctx, orgID, userID) + ret := m.ctrl.Call(m, "PasswordChangeSent", arg0, arg1, arg2) ret0, _ := ret[0].(error) return ret0 } // PasswordChangeSent indicates an expected call of PasswordChangeSent. -func (mr *MockCommandsMockRecorder) PasswordChangeSent(ctx, orgID, userID any) *gomock.Call { +func (mr *MockCommandsMockRecorder) PasswordChangeSent(arg0, arg1, arg2 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PasswordChangeSent", reflect.TypeOf((*MockCommands)(nil).PasswordChangeSent), ctx, orgID, userID) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PasswordChangeSent", reflect.TypeOf((*MockCommands)(nil).PasswordChangeSent), arg0, arg1, arg2) } // PasswordCodeSent mocks base method. -func (m *MockCommands) PasswordCodeSent(ctx context.Context, orgID, userID string, generatorInfo *senders.CodeGeneratorInfo) error { +func (m *MockCommands) PasswordCodeSent(arg0 context.Context, arg1, arg2 string, arg3 *senders.CodeGeneratorInfo) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "PasswordCodeSent", ctx, orgID, userID, generatorInfo) + ret := m.ctrl.Call(m, "PasswordCodeSent", arg0, arg1, arg2, arg3) ret0, _ := ret[0].(error) return ret0 } // PasswordCodeSent indicates an expected call of PasswordCodeSent. -func (mr *MockCommandsMockRecorder) PasswordCodeSent(ctx, orgID, userID, generatorInfo any) *gomock.Call { +func (mr *MockCommandsMockRecorder) PasswordCodeSent(arg0, arg1, arg2, arg3 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PasswordCodeSent", reflect.TypeOf((*MockCommands)(nil).PasswordCodeSent), ctx, orgID, userID, generatorInfo) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PasswordCodeSent", reflect.TypeOf((*MockCommands)(nil).PasswordCodeSent), arg0, arg1, arg2, arg3) } // RequestNotification mocks base method. -func (m *MockCommands) RequestNotification(ctx context.Context, instanceID string, request *command.NotificationRequest) error { +func (m *MockCommands) RequestNotification(arg0 context.Context, arg1 string, arg2 *command.NotificationRequest) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "RequestNotification", ctx, instanceID, request) + ret := m.ctrl.Call(m, "RequestNotification", arg0, arg1, arg2) ret0, _ := ret[0].(error) return ret0 } // RequestNotification indicates an expected call of RequestNotification. -func (mr *MockCommandsMockRecorder) RequestNotification(ctx, instanceID, request any) *gomock.Call { +func (mr *MockCommandsMockRecorder) RequestNotification(arg0, arg1, arg2 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestNotification", reflect.TypeOf((*MockCommands)(nil).RequestNotification), ctx, instanceID, request) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestNotification", reflect.TypeOf((*MockCommands)(nil).RequestNotification), arg0, arg1, arg2) } // UsageNotificationSent mocks base method. -func (m *MockCommands) UsageNotificationSent(ctx context.Context, dueEvent *quota.NotificationDueEvent) error { +func (m *MockCommands) UsageNotificationSent(arg0 context.Context, arg1 *quota.NotificationDueEvent) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UsageNotificationSent", ctx, dueEvent) + ret := m.ctrl.Call(m, "UsageNotificationSent", arg0, arg1) ret0, _ := ret[0].(error) return ret0 } // UsageNotificationSent indicates an expected call of UsageNotificationSent. -func (mr *MockCommandsMockRecorder) UsageNotificationSent(ctx, dueEvent any) *gomock.Call { +func (mr *MockCommandsMockRecorder) UsageNotificationSent(arg0, arg1 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UsageNotificationSent", reflect.TypeOf((*MockCommands)(nil).UsageNotificationSent), ctx, dueEvent) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UsageNotificationSent", reflect.TypeOf((*MockCommands)(nil).UsageNotificationSent), arg0, arg1) } // UserDomainClaimedSent mocks base method. -func (m *MockCommands) UserDomainClaimedSent(ctx context.Context, orgID, userID string) error { +func (m *MockCommands) UserDomainClaimedSent(arg0 context.Context, arg1, arg2 string) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UserDomainClaimedSent", ctx, orgID, userID) + ret := m.ctrl.Call(m, "UserDomainClaimedSent", arg0, arg1, arg2) ret0, _ := ret[0].(error) return ret0 } // UserDomainClaimedSent indicates an expected call of UserDomainClaimedSent. -func (mr *MockCommandsMockRecorder) UserDomainClaimedSent(ctx, orgID, userID any) *gomock.Call { +func (mr *MockCommandsMockRecorder) UserDomainClaimedSent(arg0, arg1, arg2 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UserDomainClaimedSent", reflect.TypeOf((*MockCommands)(nil).UserDomainClaimedSent), ctx, orgID, userID) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UserDomainClaimedSent", reflect.TypeOf((*MockCommands)(nil).UserDomainClaimedSent), arg0, arg1, arg2) } diff --git a/internal/notification/handlers/mock/queries.mock.go b/internal/notification/handlers/mock/queries.mock.go index 5ead216437..670d3f3896 100644 --- a/internal/notification/handlers/mock/queries.mock.go +++ b/internal/notification/handlers/mock/queries.mock.go @@ -26,7 +26,6 @@ import ( type MockQueries struct { ctrl *gomock.Controller recorder *MockQueriesMockRecorder - isgomock struct{} } // MockQueriesMockRecorder is the mock recorder for MockQueries. @@ -61,240 +60,240 @@ func (mr *MockQueriesMockRecorder) ActiveInstances() *gomock.Call { } // ActiveLabelPolicyByOrg mocks base method. -func (m *MockQueries) ActiveLabelPolicyByOrg(ctx context.Context, orgID string, withOwnerRemoved bool) (*query.LabelPolicy, error) { +func (m *MockQueries) ActiveLabelPolicyByOrg(arg0 context.Context, arg1 string, arg2 bool) (*query.LabelPolicy, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ActiveLabelPolicyByOrg", ctx, orgID, withOwnerRemoved) + ret := m.ctrl.Call(m, "ActiveLabelPolicyByOrg", arg0, arg1, arg2) ret0, _ := ret[0].(*query.LabelPolicy) ret1, _ := ret[1].(error) return ret0, ret1 } // ActiveLabelPolicyByOrg indicates an expected call of ActiveLabelPolicyByOrg. -func (mr *MockQueriesMockRecorder) ActiveLabelPolicyByOrg(ctx, orgID, withOwnerRemoved any) *gomock.Call { +func (mr *MockQueriesMockRecorder) ActiveLabelPolicyByOrg(arg0, arg1, arg2 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ActiveLabelPolicyByOrg", reflect.TypeOf((*MockQueries)(nil).ActiveLabelPolicyByOrg), ctx, orgID, withOwnerRemoved) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ActiveLabelPolicyByOrg", reflect.TypeOf((*MockQueries)(nil).ActiveLabelPolicyByOrg), arg0, arg1, arg2) } // ActivePrivateSigningKey mocks base method. -func (m *MockQueries) ActivePrivateSigningKey(ctx context.Context, t time.Time) (*query.PrivateKeys, error) { +func (m *MockQueries) ActivePrivateSigningKey(arg0 context.Context, arg1 time.Time) (*query.PrivateKeys, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ActivePrivateSigningKey", ctx, t) + ret := m.ctrl.Call(m, "ActivePrivateSigningKey", arg0, arg1) ret0, _ := ret[0].(*query.PrivateKeys) ret1, _ := ret[1].(error) return ret0, ret1 } // ActivePrivateSigningKey indicates an expected call of ActivePrivateSigningKey. -func (mr *MockQueriesMockRecorder) ActivePrivateSigningKey(ctx, t any) *gomock.Call { +func (mr *MockQueriesMockRecorder) ActivePrivateSigningKey(arg0, arg1 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ActivePrivateSigningKey", reflect.TypeOf((*MockQueries)(nil).ActivePrivateSigningKey), ctx, t) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ActivePrivateSigningKey", reflect.TypeOf((*MockQueries)(nil).ActivePrivateSigningKey), arg0, arg1) } // CustomTextListByTemplate mocks base method. -func (m *MockQueries) CustomTextListByTemplate(ctx context.Context, aggregateID, template string, withOwnerRemoved bool) (*query.CustomTexts, error) { +func (m *MockQueries) CustomTextListByTemplate(arg0 context.Context, arg1, arg2 string, arg3 bool) (*query.CustomTexts, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "CustomTextListByTemplate", ctx, aggregateID, template, withOwnerRemoved) + ret := m.ctrl.Call(m, "CustomTextListByTemplate", arg0, arg1, arg2, arg3) ret0, _ := ret[0].(*query.CustomTexts) ret1, _ := ret[1].(error) return ret0, ret1 } // CustomTextListByTemplate indicates an expected call of CustomTextListByTemplate. -func (mr *MockQueriesMockRecorder) CustomTextListByTemplate(ctx, aggregateID, template, withOwnerRemoved any) *gomock.Call { +func (mr *MockQueriesMockRecorder) CustomTextListByTemplate(arg0, arg1, arg2, arg3 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CustomTextListByTemplate", reflect.TypeOf((*MockQueries)(nil).CustomTextListByTemplate), ctx, aggregateID, template, withOwnerRemoved) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CustomTextListByTemplate", reflect.TypeOf((*MockQueries)(nil).CustomTextListByTemplate), arg0, arg1, arg2, arg3) } // GetActiveSigningWebKey mocks base method. -func (m *MockQueries) GetActiveSigningWebKey(ctx context.Context) (*jose.JSONWebKey, error) { +func (m *MockQueries) GetActiveSigningWebKey(arg0 context.Context) (*jose.JSONWebKey, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetActiveSigningWebKey", ctx) + ret := m.ctrl.Call(m, "GetActiveSigningWebKey", arg0) ret0, _ := ret[0].(*jose.JSONWebKey) ret1, _ := ret[1].(error) return ret0, ret1 } // GetActiveSigningWebKey indicates an expected call of GetActiveSigningWebKey. -func (mr *MockQueriesMockRecorder) GetActiveSigningWebKey(ctx any) *gomock.Call { +func (mr *MockQueriesMockRecorder) GetActiveSigningWebKey(arg0 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActiveSigningWebKey", reflect.TypeOf((*MockQueries)(nil).GetActiveSigningWebKey), ctx) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActiveSigningWebKey", reflect.TypeOf((*MockQueries)(nil).GetActiveSigningWebKey), arg0) } // GetDefaultLanguage mocks base method. -func (m *MockQueries) GetDefaultLanguage(ctx context.Context) language.Tag { +func (m *MockQueries) GetDefaultLanguage(arg0 context.Context) language.Tag { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetDefaultLanguage", ctx) + ret := m.ctrl.Call(m, "GetDefaultLanguage", arg0) ret0, _ := ret[0].(language.Tag) return ret0 } // GetDefaultLanguage indicates an expected call of GetDefaultLanguage. -func (mr *MockQueriesMockRecorder) GetDefaultLanguage(ctx any) *gomock.Call { +func (mr *MockQueriesMockRecorder) GetDefaultLanguage(arg0 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDefaultLanguage", reflect.TypeOf((*MockQueries)(nil).GetDefaultLanguage), ctx) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDefaultLanguage", reflect.TypeOf((*MockQueries)(nil).GetDefaultLanguage), arg0) } // GetInstanceRestrictions mocks base method. -func (m *MockQueries) GetInstanceRestrictions(ctx context.Context) (query.Restrictions, error) { +func (m *MockQueries) GetInstanceRestrictions(arg0 context.Context) (query.Restrictions, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetInstanceRestrictions", ctx) + ret := m.ctrl.Call(m, "GetInstanceRestrictions", arg0) ret0, _ := ret[0].(query.Restrictions) ret1, _ := ret[1].(error) return ret0, ret1 } // GetInstanceRestrictions indicates an expected call of GetInstanceRestrictions. -func (mr *MockQueriesMockRecorder) GetInstanceRestrictions(ctx any) *gomock.Call { +func (mr *MockQueriesMockRecorder) GetInstanceRestrictions(arg0 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetInstanceRestrictions", reflect.TypeOf((*MockQueries)(nil).GetInstanceRestrictions), ctx) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetInstanceRestrictions", reflect.TypeOf((*MockQueries)(nil).GetInstanceRestrictions), arg0) } // GetNotifyUserByID mocks base method. -func (m *MockQueries) GetNotifyUserByID(ctx context.Context, shouldTriggered bool, userID string) (*query.NotifyUser, error) { +func (m *MockQueries) GetNotifyUserByID(arg0 context.Context, arg1 bool, arg2 string) (*query.NotifyUser, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetNotifyUserByID", ctx, shouldTriggered, userID) + ret := m.ctrl.Call(m, "GetNotifyUserByID", arg0, arg1, arg2) ret0, _ := ret[0].(*query.NotifyUser) ret1, _ := ret[1].(error) return ret0, ret1 } // GetNotifyUserByID indicates an expected call of GetNotifyUserByID. -func (mr *MockQueriesMockRecorder) GetNotifyUserByID(ctx, shouldTriggered, userID any) *gomock.Call { +func (mr *MockQueriesMockRecorder) GetNotifyUserByID(arg0, arg1, arg2 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNotifyUserByID", reflect.TypeOf((*MockQueries)(nil).GetNotifyUserByID), ctx, shouldTriggered, userID) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNotifyUserByID", reflect.TypeOf((*MockQueries)(nil).GetNotifyUserByID), arg0, arg1, arg2) } // InstanceByID mocks base method. -func (m *MockQueries) InstanceByID(ctx context.Context, id string) (authz.Instance, error) { +func (m *MockQueries) InstanceByID(arg0 context.Context, arg1 string) (authz.Instance, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "InstanceByID", ctx, id) + ret := m.ctrl.Call(m, "InstanceByID", arg0, arg1) ret0, _ := ret[0].(authz.Instance) ret1, _ := ret[1].(error) return ret0, ret1 } // InstanceByID indicates an expected call of InstanceByID. -func (mr *MockQueriesMockRecorder) InstanceByID(ctx, id any) *gomock.Call { +func (mr *MockQueriesMockRecorder) InstanceByID(arg0, arg1 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InstanceByID", reflect.TypeOf((*MockQueries)(nil).InstanceByID), ctx, id) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InstanceByID", reflect.TypeOf((*MockQueries)(nil).InstanceByID), arg0, arg1) } // MailTemplateByOrg mocks base method. -func (m *MockQueries) MailTemplateByOrg(ctx context.Context, orgID string, withOwnerRemoved bool) (*query.MailTemplate, error) { +func (m *MockQueries) MailTemplateByOrg(arg0 context.Context, arg1 string, arg2 bool) (*query.MailTemplate, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "MailTemplateByOrg", ctx, orgID, withOwnerRemoved) + ret := m.ctrl.Call(m, "MailTemplateByOrg", arg0, arg1, arg2) ret0, _ := ret[0].(*query.MailTemplate) ret1, _ := ret[1].(error) return ret0, ret1 } // MailTemplateByOrg indicates an expected call of MailTemplateByOrg. -func (mr *MockQueriesMockRecorder) MailTemplateByOrg(ctx, orgID, withOwnerRemoved any) *gomock.Call { +func (mr *MockQueriesMockRecorder) MailTemplateByOrg(arg0, arg1, arg2 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MailTemplateByOrg", reflect.TypeOf((*MockQueries)(nil).MailTemplateByOrg), ctx, orgID, withOwnerRemoved) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MailTemplateByOrg", reflect.TypeOf((*MockQueries)(nil).MailTemplateByOrg), arg0, arg1, arg2) } // NotificationPolicyByOrg mocks base method. -func (m *MockQueries) NotificationPolicyByOrg(ctx context.Context, shouldTriggerBulk bool, orgID string, withOwnerRemoved bool) (*query.NotificationPolicy, error) { +func (m *MockQueries) NotificationPolicyByOrg(arg0 context.Context, arg1 bool, arg2 string, arg3 bool) (*query.NotificationPolicy, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "NotificationPolicyByOrg", ctx, shouldTriggerBulk, orgID, withOwnerRemoved) + ret := m.ctrl.Call(m, "NotificationPolicyByOrg", arg0, arg1, arg2, arg3) ret0, _ := ret[0].(*query.NotificationPolicy) ret1, _ := ret[1].(error) return ret0, ret1 } // NotificationPolicyByOrg indicates an expected call of NotificationPolicyByOrg. -func (mr *MockQueriesMockRecorder) NotificationPolicyByOrg(ctx, shouldTriggerBulk, orgID, withOwnerRemoved any) *gomock.Call { +func (mr *MockQueriesMockRecorder) NotificationPolicyByOrg(arg0, arg1, arg2, arg3 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NotificationPolicyByOrg", reflect.TypeOf((*MockQueries)(nil).NotificationPolicyByOrg), ctx, shouldTriggerBulk, orgID, withOwnerRemoved) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NotificationPolicyByOrg", reflect.TypeOf((*MockQueries)(nil).NotificationPolicyByOrg), arg0, arg1, arg2, arg3) } // NotificationProviderByIDAndType mocks base method. -func (m *MockQueries) NotificationProviderByIDAndType(ctx context.Context, aggID string, providerType domain.NotificationProviderType) (*query.DebugNotificationProvider, error) { +func (m *MockQueries) NotificationProviderByIDAndType(arg0 context.Context, arg1 string, arg2 domain.NotificationProviderType) (*query.DebugNotificationProvider, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "NotificationProviderByIDAndType", ctx, aggID, providerType) + ret := m.ctrl.Call(m, "NotificationProviderByIDAndType", arg0, arg1, arg2) ret0, _ := ret[0].(*query.DebugNotificationProvider) ret1, _ := ret[1].(error) return ret0, ret1 } // NotificationProviderByIDAndType indicates an expected call of NotificationProviderByIDAndType. -func (mr *MockQueriesMockRecorder) NotificationProviderByIDAndType(ctx, aggID, providerType any) *gomock.Call { +func (mr *MockQueriesMockRecorder) NotificationProviderByIDAndType(arg0, arg1, arg2 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NotificationProviderByIDAndType", reflect.TypeOf((*MockQueries)(nil).NotificationProviderByIDAndType), ctx, aggID, providerType) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NotificationProviderByIDAndType", reflect.TypeOf((*MockQueries)(nil).NotificationProviderByIDAndType), arg0, arg1, arg2) } // SMSProviderConfigActive mocks base method. -func (m *MockQueries) SMSProviderConfigActive(ctx context.Context, resourceOwner string) (*query.SMSConfig, error) { +func (m *MockQueries) SMSProviderConfigActive(arg0 context.Context, arg1 string) (*query.SMSConfig, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SMSProviderConfigActive", ctx, resourceOwner) + ret := m.ctrl.Call(m, "SMSProviderConfigActive", arg0, arg1) ret0, _ := ret[0].(*query.SMSConfig) ret1, _ := ret[1].(error) return ret0, ret1 } // SMSProviderConfigActive indicates an expected call of SMSProviderConfigActive. -func (mr *MockQueriesMockRecorder) SMSProviderConfigActive(ctx, resourceOwner any) *gomock.Call { +func (mr *MockQueriesMockRecorder) SMSProviderConfigActive(arg0, arg1 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SMSProviderConfigActive", reflect.TypeOf((*MockQueries)(nil).SMSProviderConfigActive), ctx, resourceOwner) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SMSProviderConfigActive", reflect.TypeOf((*MockQueries)(nil).SMSProviderConfigActive), arg0, arg1) } // SMTPConfigActive mocks base method. -func (m *MockQueries) SMTPConfigActive(ctx context.Context, resourceOwner string) (*query.SMTPConfig, error) { +func (m *MockQueries) SMTPConfigActive(arg0 context.Context, arg1 string) (*query.SMTPConfig, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SMTPConfigActive", ctx, resourceOwner) + ret := m.ctrl.Call(m, "SMTPConfigActive", arg0, arg1) ret0, _ := ret[0].(*query.SMTPConfig) ret1, _ := ret[1].(error) return ret0, ret1 } // SMTPConfigActive indicates an expected call of SMTPConfigActive. -func (mr *MockQueriesMockRecorder) SMTPConfigActive(ctx, resourceOwner any) *gomock.Call { +func (mr *MockQueriesMockRecorder) SMTPConfigActive(arg0, arg1 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SMTPConfigActive", reflect.TypeOf((*MockQueries)(nil).SMTPConfigActive), ctx, resourceOwner) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SMTPConfigActive", reflect.TypeOf((*MockQueries)(nil).SMTPConfigActive), arg0, arg1) } // SearchInstanceDomains mocks base method. -func (m *MockQueries) SearchInstanceDomains(ctx context.Context, queries *query.InstanceDomainSearchQueries) (*query.InstanceDomains, error) { +func (m *MockQueries) SearchInstanceDomains(arg0 context.Context, arg1 *query.InstanceDomainSearchQueries) (*query.InstanceDomains, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SearchInstanceDomains", ctx, queries) + ret := m.ctrl.Call(m, "SearchInstanceDomains", arg0, arg1) ret0, _ := ret[0].(*query.InstanceDomains) ret1, _ := ret[1].(error) return ret0, ret1 } // SearchInstanceDomains indicates an expected call of SearchInstanceDomains. -func (mr *MockQueriesMockRecorder) SearchInstanceDomains(ctx, queries any) *gomock.Call { +func (mr *MockQueriesMockRecorder) SearchInstanceDomains(arg0, arg1 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchInstanceDomains", reflect.TypeOf((*MockQueries)(nil).SearchInstanceDomains), ctx, queries) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchInstanceDomains", reflect.TypeOf((*MockQueries)(nil).SearchInstanceDomains), arg0, arg1) } // SearchMilestones mocks base method. -func (m *MockQueries) SearchMilestones(ctx context.Context, instanceIDs []string, queries *query.MilestonesSearchQueries) (*query.Milestones, error) { +func (m *MockQueries) SearchMilestones(arg0 context.Context, arg1 []string, arg2 *query.MilestonesSearchQueries) (*query.Milestones, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SearchMilestones", ctx, instanceIDs, queries) + ret := m.ctrl.Call(m, "SearchMilestones", arg0, arg1, arg2) ret0, _ := ret[0].(*query.Milestones) ret1, _ := ret[1].(error) return ret0, ret1 } // SearchMilestones indicates an expected call of SearchMilestones. -func (mr *MockQueriesMockRecorder) SearchMilestones(ctx, instanceIDs, queries any) *gomock.Call { +func (mr *MockQueriesMockRecorder) SearchMilestones(arg0, arg1, arg2 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchMilestones", reflect.TypeOf((*MockQueries)(nil).SearchMilestones), ctx, instanceIDs, queries) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchMilestones", reflect.TypeOf((*MockQueries)(nil).SearchMilestones), arg0, arg1, arg2) } // SessionByID mocks base method. -func (m *MockQueries) SessionByID(ctx context.Context, shouldTriggerBulk bool, id, sessionToken string) (*query.Session, error) { +func (m *MockQueries) SessionByID(arg0 context.Context, arg1 bool, arg2, arg3 string, arg4 domain.PermissionCheck) (*query.Session, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SessionByID", ctx, shouldTriggerBulk, id, sessionToken) + ret := m.ctrl.Call(m, "SessionByID", arg0, arg1, arg2, arg3, arg4) ret0, _ := ret[0].(*query.Session) ret1, _ := ret[1].(error) return ret0, ret1 } // SessionByID indicates an expected call of SessionByID. -func (mr *MockQueriesMockRecorder) SessionByID(ctx, shouldTriggerBulk, id, sessionToken any) *gomock.Call { +func (mr *MockQueriesMockRecorder) SessionByID(arg0, arg1, arg2, arg3, arg4 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SessionByID", reflect.TypeOf((*MockQueries)(nil).SessionByID), ctx, shouldTriggerBulk, id, sessionToken) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SessionByID", reflect.TypeOf((*MockQueries)(nil).SessionByID), arg0, arg1, arg2, arg3, arg4) } diff --git a/internal/notification/handlers/queries.go b/internal/notification/handlers/queries.go index 1c8d37598e..a3d68e4797 100644 --- a/internal/notification/handlers/queries.go +++ b/internal/notification/handlers/queries.go @@ -20,7 +20,7 @@ type Queries interface { GetNotifyUserByID(ctx context.Context, shouldTriggered bool, userID string) (*query.NotifyUser, error) CustomTextListByTemplate(ctx context.Context, aggregateID, template string, withOwnerRemoved bool) (*query.CustomTexts, error) SearchInstanceDomains(ctx context.Context, queries *query.InstanceDomainSearchQueries) (*query.InstanceDomains, error) - SessionByID(ctx context.Context, shouldTriggerBulk bool, id, sessionToken string) (*query.Session, error) + SessionByID(ctx context.Context, shouldTriggerBulk bool, id, sessionToken string, check domain.PermissionCheck) (*query.Session, error) NotificationPolicyByOrg(ctx context.Context, shouldTriggerBulk bool, orgID string, withOwnerRemoved bool) (*query.NotificationPolicy, error) SearchMilestones(ctx context.Context, instanceIDs []string, queries *query.MilestonesSearchQueries) (*query.Milestones, error) NotificationProviderByIDAndType(ctx context.Context, aggID string, providerType domain.NotificationProviderType) (*query.DebugNotificationProvider, error) diff --git a/internal/notification/handlers/user_notifier.go b/internal/notification/handlers/user_notifier.go index ec30ab476f..c24b87c2f6 100644 --- a/internal/notification/handlers/user_notifier.go +++ b/internal/notification/handlers/user_notifier.go @@ -400,7 +400,7 @@ func (u *userNotifier) reduceSessionOTPSMSChallenged(event eventstore.Event) (*h if alreadyHandled { return nil } - s, err := u.queries.SessionByID(ctx, true, e.Aggregate().ID, "") + s, err := u.queries.SessionByID(ctx, true, e.Aggregate().ID, "", nil) if err != nil { return err } @@ -496,7 +496,7 @@ func (u *userNotifier) reduceSessionOTPEmailChallenged(event eventstore.Event) ( if alreadyHandled { return nil } - s, err := u.queries.SessionByID(ctx, true, e.Aggregate().ID, "") + s, err := u.queries.SessionByID(ctx, true, e.Aggregate().ID, "", nil) if err != nil { return err } diff --git a/internal/notification/handlers/user_notifier_legacy.go b/internal/notification/handlers/user_notifier_legacy.go index 7df31cdf91..4bfa1a796e 100644 --- a/internal/notification/handlers/user_notifier_legacy.go +++ b/internal/notification/handlers/user_notifier_legacy.go @@ -324,7 +324,7 @@ func (u *userNotifierLegacy) reduceSessionOTPSMSChallenged(event eventstore.Even return handler.NewNoOpStatement(e), nil } ctx := HandlerContext(event.Aggregate()) - s, err := u.queries.SessionByID(ctx, true, e.Aggregate().ID, "") + s, err := u.queries.SessionByID(ctx, true, e.Aggregate().ID, "", nil) if err != nil { return nil, err } @@ -428,7 +428,7 @@ func (u *userNotifierLegacy) reduceSessionOTPEmailChallenged(event eventstore.Ev return handler.NewNoOpStatement(e), nil } ctx := HandlerContext(event.Aggregate()) - s, err := u.queries.SessionByID(ctx, true, e.Aggregate().ID, "") + s, err := u.queries.SessionByID(ctx, true, e.Aggregate().ID, "", nil) if err != nil { return nil, err } diff --git a/internal/notification/handlers/user_notifier_legacy_test.go b/internal/notification/handlers/user_notifier_legacy_test.go index fe99eaa572..02f21670f5 100644 --- a/internal/notification/handlers/user_notifier_legacy_test.go +++ b/internal/notification/handlers/user_notifier_legacy_test.go @@ -1228,7 +1228,7 @@ func Test_userNotifierLegacy_reduceOTPEmailChallenged(t *testing.T) { } codeAlg, code := cryptoValue(t, ctrl, "testcode") expectTemplateWithNotifyUserQueries(queries, givenTemplate) - queries.EXPECT().SessionByID(gomock.Any(), gomock.Any(), userID, gomock.Any()).Return(&query.Session{}, nil) + queries.EXPECT().SessionByID(gomock.Any(), gomock.Any(), userID, gomock.Any(), nil).Return(&query.Session{}, nil) commands.EXPECT().OTPEmailSent(gomock.Any(), userID, orgID).Return(nil) return fields{ queries: queries, @@ -1264,7 +1264,7 @@ func Test_userNotifierLegacy_reduceOTPEmailChallenged(t *testing.T) { } codeAlg, code := cryptoValue(t, ctrl, "testcode") expectTemplateWithNotifyUserQueries(queries, givenTemplate) - queries.EXPECT().SessionByID(gomock.Any(), gomock.Any(), userID, gomock.Any()).Return(&query.Session{}, nil) + queries.EXPECT().SessionByID(gomock.Any(), gomock.Any(), userID, gomock.Any(), nil).Return(&query.Session{}, nil) queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ Domains: []*query.InstanceDomain{{ Domain: instancePrimaryDomain, @@ -1306,7 +1306,7 @@ func Test_userNotifierLegacy_reduceOTPEmailChallenged(t *testing.T) { } codeAlg, code := cryptoValue(t, ctrl, testCode) expectTemplateWithNotifyUserQueries(queries, givenTemplate) - queries.EXPECT().SessionByID(gomock.Any(), gomock.Any(), userID, gomock.Any()).Return(&query.Session{}, nil) + queries.EXPECT().SessionByID(gomock.Any(), gomock.Any(), userID, gomock.Any(), nil).Return(&query.Session{}, nil) commands.EXPECT().OTPEmailSent(gomock.Any(), userID, orgID).Return(nil) return fields{ queries: queries, @@ -1350,7 +1350,7 @@ func Test_userNotifierLegacy_reduceOTPEmailChallenged(t *testing.T) { }}, }, nil) expectTemplateWithNotifyUserQueries(queries, givenTemplate) - queries.EXPECT().SessionByID(gomock.Any(), gomock.Any(), userID, gomock.Any()).Return(&query.Session{}, nil) + queries.EXPECT().SessionByID(gomock.Any(), gomock.Any(), userID, gomock.Any(), nil).Return(&query.Session{}, nil) commands.EXPECT().OTPEmailSent(gomock.Any(), userID, orgID).Return(nil) return fields{ queries: queries, @@ -1386,7 +1386,7 @@ func Test_userNotifierLegacy_reduceOTPEmailChallenged(t *testing.T) { } codeAlg, code := cryptoValue(t, ctrl, testCode) expectTemplateWithNotifyUserQueries(queries, givenTemplate) - queries.EXPECT().SessionByID(gomock.Any(), gomock.Any(), userID, gomock.Any()).Return(&query.Session{}, nil) + queries.EXPECT().SessionByID(gomock.Any(), gomock.Any(), userID, gomock.Any(), nil).Return(&query.Session{}, nil) commands.EXPECT().OTPEmailSent(gomock.Any(), userID, orgID).Return(nil) return fields{ queries: queries, @@ -1445,7 +1445,7 @@ func Test_userNotifierLegacy_reduceOTPSMSChallenged(t *testing.T) { Content: expectContent, } expectTemplateWithNotifyUserQueriesSMS(queries) - queries.EXPECT().SessionByID(gomock.Any(), gomock.Any(), userID, gomock.Any()).Return(&query.Session{}, nil) + queries.EXPECT().SessionByID(gomock.Any(), gomock.Any(), userID, gomock.Any(), nil).Return(&query.Session{}, nil) commands.EXPECT().OTPSMSSent(gomock.Any(), userID, orgID, &senders.CodeGeneratorInfo{ID: smsProviderID, VerificationID: verificationID}).Return(nil) return fields{ queries: queries, @@ -1481,7 +1481,7 @@ func Test_userNotifierLegacy_reduceOTPSMSChallenged(t *testing.T) { Content: expectContent, } expectTemplateWithNotifyUserQueriesSMS(queries) - queries.EXPECT().SessionByID(gomock.Any(), gomock.Any(), userID, gomock.Any()).Return(&query.Session{}, nil) + queries.EXPECT().SessionByID(gomock.Any(), gomock.Any(), userID, gomock.Any(), nil).Return(&query.Session{}, nil) queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ Domains: []*query.InstanceDomain{{ Domain: instancePrimaryDomain, diff --git a/internal/notification/handlers/user_notifier_test.go b/internal/notification/handlers/user_notifier_test.go index b57edcc57c..b7b7ceb446 100644 --- a/internal/notification/handlers/user_notifier_test.go +++ b/internal/notification/handlers/user_notifier_test.go @@ -980,7 +980,7 @@ func Test_userNotifier_reduceOTPEmailChallenged(t *testing.T) { name: "url with event trigger", test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { _, code := cryptoValue(t, ctrl, "testCode") - queries.EXPECT().SessionByID(gomock.Any(), gomock.Any(), sessionID, gomock.Any()).Return(&query.Session{ + queries.EXPECT().SessionByID(gomock.Any(), gomock.Any(), sessionID, gomock.Any(), nil).Return(&query.Session{ ID: sessionID, ResourceOwner: instanceID, UserFactor: query.SessionUserFactor{ @@ -1044,7 +1044,7 @@ func Test_userNotifier_reduceOTPEmailChallenged(t *testing.T) { IsPrimary: true, }}, }, nil) - queries.EXPECT().SessionByID(gomock.Any(), gomock.Any(), sessionID, gomock.Any()).Return(&query.Session{ + queries.EXPECT().SessionByID(gomock.Any(), gomock.Any(), sessionID, gomock.Any(), nil).Return(&query.Session{ ID: sessionID, ResourceOwner: instanceID, UserFactor: query.SessionUserFactor{ @@ -1129,7 +1129,7 @@ func Test_userNotifier_reduceOTPEmailChallenged(t *testing.T) { name: "url template", test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { _, code := cryptoValue(t, ctrl, "testCode") - queries.EXPECT().SessionByID(gomock.Any(), gomock.Any(), sessionID, gomock.Any()).Return(&query.Session{ + queries.EXPECT().SessionByID(gomock.Any(), gomock.Any(), sessionID, gomock.Any(), nil).Return(&query.Session{ ID: sessionID, ResourceOwner: instanceID, UserFactor: query.SessionUserFactor{ @@ -1220,7 +1220,7 @@ func Test_userNotifier_reduceOTPSMSChallenged(t *testing.T) { test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { testCode := "testcode" _, code := cryptoValue(t, ctrl, testCode) - queries.EXPECT().SessionByID(gomock.Any(), gomock.Any(), sessionID, gomock.Any()).Return(&query.Session{ + queries.EXPECT().SessionByID(gomock.Any(), gomock.Any(), sessionID, gomock.Any(), nil).Return(&query.Session{ ID: sessionID, ResourceOwner: instanceID, UserFactor: query.SessionUserFactor{ @@ -1284,7 +1284,7 @@ func Test_userNotifier_reduceOTPSMSChallenged(t *testing.T) { IsPrimary: true, }}, }, nil) - queries.EXPECT().SessionByID(gomock.Any(), gomock.Any(), sessionID, gomock.Any()).Return(&query.Session{ + queries.EXPECT().SessionByID(gomock.Any(), gomock.Any(), sessionID, gomock.Any(), nil).Return(&query.Session{ ID: sessionID, ResourceOwner: instanceID, UserFactor: query.SessionUserFactor{ @@ -1339,7 +1339,7 @@ func Test_userNotifier_reduceOTPSMSChallenged(t *testing.T) { { name: "external code", test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { - queries.EXPECT().SessionByID(gomock.Any(), gomock.Any(), sessionID, gomock.Any()).Return(&query.Session{ + queries.EXPECT().SessionByID(gomock.Any(), gomock.Any(), sessionID, gomock.Any(), nil).Return(&query.Session{ ID: sessionID, ResourceOwner: instanceID, UserFactor: query.SessionUserFactor{ diff --git a/internal/query/session.go b/internal/query/session.go index 54afbde064..d30fe4cda9 100644 --- a/internal/query/session.go +++ b/internal/query/session.go @@ -6,6 +6,7 @@ import ( "errors" "net" "net/http" + "slices" "time" sq "github.com/Masterminds/squirrel" @@ -80,6 +81,39 @@ type SessionsSearchQueries struct { Queries []SearchQuery } +func sessionsCheckPermission(ctx context.Context, sessions *Sessions, permissionCheck domain.PermissionCheck) { + sessions.Sessions = slices.DeleteFunc(sessions.Sessions, + func(session *Session) bool { + return sessionCheckPermission(ctx, session.ResourceOwner, session.Creator, session.UserAgent, session.UserFactor, permissionCheck) != nil + }, + ) +} + +func sessionCheckPermission(ctx context.Context, resourceOwner string, creator string, useragent domain.UserAgent, userFactor SessionUserFactor, permissionCheck domain.PermissionCheck) error { + data := authz.GetCtxData(ctx) + // no permission check necessary if user is creator + if data.UserID == creator { + return nil + } + // no permission check necessary if session belongs to the user + if userFactor.UserID != "" && data.UserID == userFactor.UserID { + return nil + } + // no permission check necessary if session belongs to the same useragent as used + if data.AgentID != "" && useragent.FingerprintID != nil && *useragent.FingerprintID != "" && data.AgentID == *useragent.FingerprintID { + return nil + } + // if session belongs to a user, check for permission on the user resource + if userFactor.ResourceOwner != "" { + if err := permissionCheck(ctx, domain.PermissionSessionRead, userFactor.ResourceOwner, userFactor.UserID); err != nil { + return err + } + return nil + } + // default, check for permission on instance + return permissionCheck(ctx, domain.PermissionSessionRead, resourceOwner, "") +} + func (q *SessionsSearchQueries) toQuery(query sq.SelectBuilder) sq.SelectBuilder { query = q.SearchRequest.toQuery(query) for _, q := range q.Queries { @@ -195,7 +229,24 @@ var ( } ) -func (q *Queries) SessionByID(ctx context.Context, shouldTriggerBulk bool, id, sessionToken string) (session *Session, err error) { +func (q *Queries) SessionByID(ctx context.Context, shouldTriggerBulk bool, id, sessionToken string, permissionCheck domain.PermissionCheck) (session *Session, err error) { + session, tokenID, err := q.sessionByID(ctx, shouldTriggerBulk, id) + if err != nil { + return nil, err + } + if sessionToken == "" { + if err := sessionCheckPermission(ctx, session.ResourceOwner, session.Creator, session.UserAgent, session.UserFactor, permissionCheck); err != nil { + return nil, err + } + return session, nil + } + if err := q.sessionTokenVerifier(ctx, sessionToken, session.ID, tokenID); err != nil { + return nil, zerrors.ThrowPermissionDenied(nil, "QUERY-dsfr3", "Errors.PermissionDenied") + } + return session, nil +} + +func (q *Queries) sessionByID(ctx context.Context, shouldTriggerBulk bool, id string) (session *Session, tokenID string, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -214,27 +265,31 @@ func (q *Queries) SessionByID(ctx context.Context, shouldTriggerBulk bool, id, s }, ).ToSql() if err != nil { - return nil, zerrors.ThrowInternal(err, "QUERY-dn9JW", "Errors.Query.SQLStatement") + return nil, "", zerrors.ThrowInternal(err, "QUERY-dn9JW", "Errors.Query.SQLStatement") } - var tokenID string err = q.client.QueryRowContext(ctx, func(row *sql.Row) error { session, tokenID, err = scan(row) return err }, stmt, args...) if err != nil { - return nil, err + return nil, "", err } - if sessionToken == "" { - return session, nil - } - if err := q.sessionTokenVerifier(ctx, sessionToken, session.ID, tokenID); err != nil { - return nil, zerrors.ThrowPermissionDenied(nil, "QUERY-dsfr3", "Errors.PermissionDenied") - } - return session, nil + return session, tokenID, nil } -func (q *Queries) SearchSessions(ctx context.Context, queries *SessionsSearchQueries) (sessions *Sessions, err error) { +func (q *Queries) SearchSessions(ctx context.Context, queries *SessionsSearchQueries, permissionCheck domain.PermissionCheck) (*Sessions, error) { + sessions, err := q.searchSessions(ctx, queries) + if err != nil { + return nil, err + } + if permissionCheck != nil { + sessionsCheckPermission(ctx, sessions, permissionCheck) + } + return sessions, nil +} + +func (q *Queries) searchSessions(ctx context.Context, queries *SessionsSearchQueries) (sessions *Sessions, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -272,6 +327,10 @@ func NewSessionCreatorSearchQuery(creator string) (SearchQuery, error) { return NewTextQuery(SessionColumnCreator, creator, TextEquals) } +func NewSessionUserAgentFingerprintIDSearchQuery(fingerprintID string) (SearchQuery, error) { + return NewTextQuery(SessionColumnUserAgentFingerprintID, fingerprintID, TextEquals) +} + func NewUserIDSearchQuery(id string) (SearchQuery, error) { return NewTextQuery(SessionColumnUserID, id, TextEquals) } @@ -415,6 +474,10 @@ func prepareSessionsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBui SessionColumnOTPSMSCheckedAt.identifier(), SessionColumnOTPEmailCheckedAt.identifier(), SessionColumnMetadata.identifier(), + SessionColumnUserAgentFingerprintID.identifier(), + SessionColumnUserAgentIP.identifier(), + SessionColumnUserAgentDescription.identifier(), + SessionColumnUserAgentHeader.identifier(), SessionColumnExpiration.identifier(), countColumn.identifier(), ).From(sessionsTable.identifier()). @@ -441,6 +504,8 @@ func prepareSessionsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBui otpSMSCheckedAt sql.NullTime otpEmailCheckedAt sql.NullTime metadata database.Map[[]byte] + userAgentIP sql.NullString + userAgentHeader database.Map[[]string] expiration sql.NullTime ) @@ -465,6 +530,10 @@ func prepareSessionsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBui &otpSMSCheckedAt, &otpEmailCheckedAt, &metadata, + &session.UserAgent.FingerprintID, + &userAgentIP, + &session.UserAgent.Description, + &userAgentHeader, &expiration, &sessions.Count, ) @@ -485,6 +554,10 @@ func prepareSessionsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBui session.OTPSMSFactor.OTPCheckedAt = otpSMSCheckedAt.Time session.OTPEmailFactor.OTPCheckedAt = otpEmailCheckedAt.Time session.Metadata = metadata + session.UserAgent.Header = http.Header(userAgentHeader) + if userAgentIP.Valid { + session.UserAgent.IP = net.ParseIP(userAgentIP.String) + } session.Expiration = expiration.Time sessions.Sessions = append(sessions.Sessions, session) diff --git a/internal/query/sessions_test.go b/internal/query/sessions_test.go index c7929a98a8..4109969262 100644 --- a/internal/query/sessions_test.go +++ b/internal/query/sessions_test.go @@ -15,6 +15,7 @@ import ( "github.com/muhlemmer/gu" "github.com/stretchr/testify/require" + "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/zerrors" ) @@ -71,6 +72,10 @@ var ( ` projections.sessions8.otp_sms_checked_at,` + ` projections.sessions8.otp_email_checked_at,` + ` projections.sessions8.metadata,` + + ` projections.sessions8.user_agent_fingerprint_id,` + + ` projections.sessions8.user_agent_ip,` + + ` projections.sessions8.user_agent_description,` + + ` projections.sessions8.user_agent_header,` + ` projections.sessions8.expiration,` + ` COUNT(*) OVER ()` + ` FROM projections.sessions8` + @@ -129,6 +134,10 @@ var ( "otp_sms_checked_at", "otp_email_checked_at", "metadata", + "user_agent_fingerprint_id", + "user_agent_ip", + "user_agent_description", + "user_agent_header", "expiration", "count", } @@ -186,6 +195,10 @@ func Test_SessionsPrepare(t *testing.T) { testNow, testNow, []byte(`{"key": "dmFsdWU="}`), + "fingerPrintID", + "1.2.3.4", + "agentDescription", + []byte(`{"foo":["foo","bar"]}`), testNow, }, }, @@ -233,6 +246,12 @@ func Test_SessionsPrepare(t *testing.T) { Metadata: map[string][]byte{ "key": []byte("value"), }, + UserAgent: domain.UserAgent{ + FingerprintID: gu.Ptr("fingerPrintID"), + IP: net.IPv4(1, 2, 3, 4), + Description: gu.Ptr("agentDescription"), + Header: http.Header{"foo": []string{"foo", "bar"}}, + }, Expiration: testNow, }, }, @@ -267,6 +286,10 @@ func Test_SessionsPrepare(t *testing.T) { testNow, testNow, []byte(`{"key": "dmFsdWU="}`), + "fingerPrintID", + "1.2.3.4", + "agentDescription", + []byte(`{"foo":["foo","bar"]}`), testNow, }, { @@ -290,6 +313,10 @@ func Test_SessionsPrepare(t *testing.T) { testNow, testNow, []byte(`{"key": "dmFsdWU="}`), + "fingerPrintID", + "1.2.3.4", + "agentDescription", + []byte(`{"foo":["foo","bar"]}`), testNow, }, }, @@ -337,6 +364,12 @@ func Test_SessionsPrepare(t *testing.T) { Metadata: map[string][]byte{ "key": []byte("value"), }, + UserAgent: domain.UserAgent{ + FingerprintID: gu.Ptr("fingerPrintID"), + IP: net.IPv4(1, 2, 3, 4), + Description: gu.Ptr("agentDescription"), + Header: http.Header{"foo": []string{"foo", "bar"}}, + }, Expiration: testNow, }, { @@ -376,6 +409,12 @@ func Test_SessionsPrepare(t *testing.T) { Metadata: map[string][]byte{ "key": []byte("value"), }, + UserAgent: domain.UserAgent{ + FingerprintID: gu.Ptr("fingerPrintID"), + IP: net.IPv4(1, 2, 3, 4), + Description: gu.Ptr("agentDescription"), + Header: http.Header{"foo": []string{"foo", "bar"}}, + }, Expiration: testNow, }, }, @@ -553,3 +592,157 @@ func prepareSessionQueryTesting(t *testing.T, token string) func(context.Context } } } + +func Test_sessionCheckPermission(t *testing.T) { + type args struct { + ctx context.Context + resourceOwner string + creator string + useragent domain.UserAgent + userFactor SessionUserFactor + permissionCheck domain.PermissionCheck + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "permission check, no user in context", + args: args{ + ctx: authz.NewMockContextWithAgent("instance", "org", "", ""), + resourceOwner: "instance", + creator: "creator", + permissionCheck: expectedFailedPermissionCheck("instance", ""), + }, + wantErr: true, + }, + { + name: "permission check, factor, no user in context", + args: args{ + ctx: authz.NewMockContextWithAgent("instance", "org", "", ""), + resourceOwner: "instance", + creator: "creator", + userFactor: SessionUserFactor{ResourceOwner: "resourceowner", UserID: "user"}, + permissionCheck: expectedFailedPermissionCheck("resourceowner", "user"), + }, + wantErr: true, + }, + { + name: "no permission check, creator", + args: args{ + ctx: authz.NewMockContextWithAgent("instance", "org", "user", "agent"), + resourceOwner: "instance", + creator: "user", + }, + wantErr: false, + }, + { + name: "no permission check, same user", + args: args{ + ctx: authz.NewMockContextWithAgent("instance", "org", "user", "agent"), + resourceOwner: "instance", + creator: "creator", + userFactor: SessionUserFactor{UserID: "user"}, + }, + wantErr: false, + }, + { + name: "no permission check, same useragent", + args: args{ + ctx: authz.NewMockContextWithAgent("instance", "org", "user1", "agent"), + resourceOwner: "instance", + creator: "creator", + userFactor: SessionUserFactor{UserID: "user2"}, + useragent: domain.UserAgent{ + FingerprintID: gu.Ptr("agent"), + }, + }, + wantErr: false, + }, + { + name: "permission check, factor", + args: args{ + ctx: authz.NewMockContextWithAgent("instance", "org", "user", "agent"), + resourceOwner: "instance", + creator: "not-user", + useragent: domain.UserAgent{ + FingerprintID: gu.Ptr("not-agent"), + }, + userFactor: SessionUserFactor{UserID: "user2", ResourceOwner: "resourceowner2"}, + permissionCheck: expectedSuccessfulPermissionCheck("resourceowner2", "user2"), + }, + wantErr: false, + }, + { + name: "permission check, factor, error", + args: args{ + ctx: authz.NewMockContextWithAgent("instance", "org", "user", "agent"), + resourceOwner: "instance", + creator: "not-user", + useragent: domain.UserAgent{ + FingerprintID: gu.Ptr("not-agent"), + }, + userFactor: SessionUserFactor{UserID: "user2", ResourceOwner: "resourceowner2"}, + permissionCheck: expectedFailedPermissionCheck("resourceowner2", "user2"), + }, + wantErr: true, + }, + { + name: "permission check", + args: args{ + ctx: authz.NewMockContextWithAgent("instance", "org", "user", "agent"), + resourceOwner: "instance", + creator: "not-user", + useragent: domain.UserAgent{ + FingerprintID: gu.Ptr("not-agent"), + }, + userFactor: SessionUserFactor{}, + permissionCheck: expectedSuccessfulPermissionCheck("instance", ""), + }, + wantErr: false, + }, + { + name: "permission check, error", + args: args{ + ctx: authz.NewMockContextWithAgent("instance", "org", "user", "agent"), + resourceOwner: "instance", + creator: "not-user", + useragent: domain.UserAgent{ + FingerprintID: gu.Ptr("not-agent"), + }, + userFactor: SessionUserFactor{}, + permissionCheck: expectedFailedPermissionCheck("instance", ""), + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := sessionCheckPermission(tt.args.ctx, tt.args.resourceOwner, tt.args.creator, tt.args.useragent, tt.args.userFactor, tt.args.permissionCheck) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + }) + } +} + +func expectedSuccessfulPermissionCheck(resourceOwner, userID string) func(ctx context.Context, permission, orgID, resourceID string) (err error) { + return func(ctx context.Context, permission, orgID, resourceID string) (err error) { + if orgID == resourceOwner && resourceID == userID { + return nil + } + return fmt.Errorf("permission check failed: %s %s", orgID, resourceID) + } +} + +func expectedFailedPermissionCheck(resourceOwner, userID string) func(ctx context.Context, permission, orgID, resourceID string) (err error) { + return func(ctx context.Context, permission, orgID, resourceID string) (err error) { + if orgID == resourceOwner && resourceID == userID { + return fmt.Errorf("permission check failed: %s %s", orgID, resourceID) + } + return nil + } +} diff --git a/proto/zitadel/session/v2/session.proto b/proto/zitadel/session/v2/session.proto index 2c17d81f99..7ab6b77610 100644 --- a/proto/zitadel/session/v2/session.proto +++ b/proto/zitadel/session/v2/session.proto @@ -136,6 +136,8 @@ message SearchQuery { IDsQuery ids_query = 1; UserIDQuery user_id_query = 2; CreationDateQuery creation_date_query = 3; + CreatorQuery creator_query = 4; + UserAgentQuery user_agent_query = 5; } } @@ -157,9 +159,33 @@ message CreationDateQuery { ]; } +message CreatorQuery { + // ID of the user who created the session. If empty, the calling user's ID is used. + optional string id = 1 [ + (validate.rules).string = {max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + max_length: 200; + example: "\"69629023906488334\""; + } + ]; +} + +message UserAgentQuery { + // Finger print id of the user agent used for the session. + // Set an empty fingerprint_id to use the user agent from the call. + // If the user agent is not available from the current token, an error will be returned. + optional string fingerprint_id = 1 [ + (validate.rules).string = {max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + max_length: 200; + example: "\"69629023906488334\""; + } + ]; +} + message UserAgent { optional string fingerprint_id = 1; - optional string ip = 2; + optional string ip = 2; optional string description = 3; // A header may have multiple values. @@ -169,7 +195,7 @@ message UserAgent { message HeaderValues { repeated string values = 1; } - map header = 4; + map header = 4; } enum SessionFieldName { From d01d003a0326de8ea3102c4b28733322ac9d70cd Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 14 Jan 2025 15:44:41 +0100 Subject: [PATCH 18/30] feat: replace user scim v2 endpoint (#9163) # Which Problems Are Solved - Adds support for the replace user SCIM v2 endpoint # How the Problems Are Solved - Adds support for the replace user SCIM v2 endpoint under `PUT /scim/v2/{orgID}/Users/{id}` # Additional Changes - Respect the `Active` field in the SCIM v2 create user endpoint `POST /scim/v2/{orgID}/Users` - Eventually consistent read endpoints used in SCIM tests are wrapped in `assert.EventuallyWithT` to work around race conditions # Additional Context Part of #8140 --- internal/api/scim/authz.go | 3 + .../users_create_test_minimal_inactive.json | 17 + .../testdata/users_replace_test_full.json | 116 ++++++ .../testdata/users_replace_test_minimal.json | 16 + ...replace_test_minimal_with_external_id.json | 17 + .../integration_test/users_create_test.go | 239 ++++++++++--- .../integration_test/users_delete_test.go | 10 +- .../scim/integration_test/users_get_test.go | 85 +++-- .../integration_test/users_replace_test.go | 329 ++++++++++++++++++ .../api/scim/resources/resource_handler.go | 1 + .../resources/resource_handler_adapter.go | 10 + internal/api/scim/resources/user.go | 19 +- internal/api/scim/resources/user_mapping.go | 141 +++++++- internal/api/scim/resources/user_metadata.go | 23 ++ internal/api/scim/server.go | 1 + internal/command/user_human.go | 2 + internal/command/user_model.go | 4 + internal/command/user_v2_human.go | 79 ++++- internal/integration/assert.go | 8 +- internal/integration/scim/client.go | 4 + 20 files changed, 1029 insertions(+), 95 deletions(-) create mode 100644 internal/api/scim/integration_test/testdata/users_create_test_minimal_inactive.json create mode 100644 internal/api/scim/integration_test/testdata/users_replace_test_full.json create mode 100644 internal/api/scim/integration_test/testdata/users_replace_test_minimal.json create mode 100644 internal/api/scim/integration_test/testdata/users_replace_test_minimal_with_external_id.json create mode 100644 internal/api/scim/integration_test/users_replace_test.go diff --git a/internal/api/scim/authz.go b/internal/api/scim/authz.go index 26b4ecf10f..1ab174e7b3 100644 --- a/internal/api/scim/authz.go +++ b/internal/api/scim/authz.go @@ -13,6 +13,9 @@ var AuthMapping = authz.MethodMapping{ "GET:/scim/v2/" + http.OrgIdInPathVariable + "/Users/{id}": { Permission: domain.PermissionUserRead, }, + "PUT:/scim/v2/" + http.OrgIdInPathVariable + "/Users/{id}": { + Permission: domain.PermissionUserWrite, + }, "DELETE:/scim/v2/" + http.OrgIdInPathVariable + "/Users/{id}": { Permission: domain.PermissionUserDelete, }, diff --git a/internal/api/scim/integration_test/testdata/users_create_test_minimal_inactive.json b/internal/api/scim/integration_test/testdata/users_create_test_minimal_inactive.json new file mode 100644 index 0000000000..11650674a6 --- /dev/null +++ b/internal/api/scim/integration_test/testdata/users_create_test_minimal_inactive.json @@ -0,0 +1,17 @@ +{ + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:User" + ], + "userName": "acmeUser1", + "name": { + "familyName": "Ross", + "givenName": "Bethany" + }, + "emails": [ + { + "value": "user1@example.com", + "primary": true + } + ], + "active": false +} \ No newline at end of file diff --git a/internal/api/scim/integration_test/testdata/users_replace_test_full.json b/internal/api/scim/integration_test/testdata/users_replace_test_full.json new file mode 100644 index 0000000000..83ff72b697 --- /dev/null +++ b/internal/api/scim/integration_test/testdata/users_replace_test_full.json @@ -0,0 +1,116 @@ +{ + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "externalId": "701984-updated", + "userName": "bjensen-replaced-full@example.com", + "name": { + "formatted": "Ms. Barbara J Jensen, III-updated", + "familyName": "Jensen-updated", + "givenName": "Barbara-updated", + "middleName": "Jane-updated", + "honorificPrefix": "Ms.-updated", + "honorificSuffix": "III" + }, + "displayName": "Babs Jensen-updated", + "nickName": "Babs-updated", + "profileUrl": "http://login.example.com/bjensen-updated", + "emails": [ + { + "value": "bjensen-replaced-full@example.com", + "type": "work-updated", + "primary": true + }, + { + "value": "babs-replaced-full@jensen.org", + "type": "home-updated" + } + ], + "addresses": [ + { + "type": "work-updated", + "streetAddress": "100 Universal City Plaza-updated", + "locality": "Hollywood-updated", + "region": "CA-updated", + "postalCode": "91608-updated", + "country": "USA-updated", + "formatted": "100 Universal City Plaza\nHollywood, CA 91608 USA-updated", + "primary": true + }, + { + "type": "home-updated", + "streetAddress": "456 Hollywood Blvd-updated", + "locality": "Hollywood-updated", + "region": "CA-updated", + "postalCode": "91608-updated", + "country": "USA-updated", + "formatted": "456 Hollywood Blvd\nHollywood, CA 91608 USA-updated" + } + ], + "phoneNumbers": [ + { + "value": "555-555-5555-updated", + "type": "work-updated", + "primary": true + }, + { + "value": "555-555-4444-updated", + "type": "mobile-updated" + } + ], + "ims": [ + { + "value": "someaimhandle-updated", + "type": "aim-updated" + }, + { + "value": "twitterhandle-updated", + "type": "X-updated" + } + ], + "photos": [ + { + "value": + "https://photos.example.com/profilephoto/72930000000Ccne/F-updated", + "type": "photo-updated" + }, + { + "value": + "https://photos.example.com/profilephoto/72930000000Ccne/T-updated", + "type": "thumbnail-updated" + } + ], + "roles": [ + { + "value": "my-role-1-updated", + "display": "Rolle 1-updated", + "type": "main-role-updated", + "primary": true + }, + { + "value": "my-role-2-updated", + "display": "Rolle 2-updated", + "type": "secondary-role-updated", + "primary": false + } + ], + "entitlements": [ + { + "value": "my-entitlement-1-updated", + "display": "Entitlement 1-updated", + "type": "main-entitlement-updated", + "primary": true + }, + { + "value": "my-entitlement-2-updated", + "display": "Entitlement 2-updated", + "type": "secondary-entitlement-updated", + "primary": false + } + ], + "userType": "Employee-updated", + "title": "Tour Guide-updated", + "preferredLanguage": "en-CH", + "locale": "en-CH", + "timezone": "Europe/Zurich", + "active": false, + "password": "Password1!-updated" +} \ No newline at end of file diff --git a/internal/api/scim/integration_test/testdata/users_replace_test_minimal.json b/internal/api/scim/integration_test/testdata/users_replace_test_minimal.json new file mode 100644 index 0000000000..f8756bf4a4 --- /dev/null +++ b/internal/api/scim/integration_test/testdata/users_replace_test_minimal.json @@ -0,0 +1,16 @@ +{ + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:User" + ], + "userName": "acmeUser1-minimal-replaced", + "name": { + "familyName": "Ross-replaced", + "givenName": "Bethany-replaced" + }, + "emails": [ + { + "value": "user1-minimal-replaced@example.com", + "primary": true + } + ] +} \ No newline at end of file diff --git a/internal/api/scim/integration_test/testdata/users_replace_test_minimal_with_external_id.json b/internal/api/scim/integration_test/testdata/users_replace_test_minimal_with_external_id.json new file mode 100644 index 0000000000..d02e605976 --- /dev/null +++ b/internal/api/scim/integration_test/testdata/users_replace_test_minimal_with_external_id.json @@ -0,0 +1,17 @@ +{ + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:User" + ], + "externalID": "replaced-external-id", + "userName": "acmeUser1-replaced-with-external-id", + "name": { + "familyName": "Ross", + "givenName": "Bethany" + }, + "emails": [ + { + "value": "user1-minimal-replaced-with-external-id@example.com", + "primary": true + } + ] +} \ No newline at end of file diff --git a/internal/api/scim/integration_test/users_create_test.go b/internal/api/scim/integration_test/users_create_test.go index b7d97e342f..1a3e2b8dd5 100644 --- a/internal/api/scim/integration_test/users_create_test.go +++ b/internal/api/scim/integration_test/users_create_test.go @@ -6,23 +6,30 @@ import ( "context" _ "embed" "github.com/brianvoe/gofakeit/v6" + "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/zitadel/zitadel/internal/api/scim/resources" "github.com/zitadel/zitadel/internal/api/scim/schemas" "github.com/zitadel/zitadel/internal/integration" "github.com/zitadel/zitadel/internal/integration/scim" "github.com/zitadel/zitadel/pkg/grpc/management" "github.com/zitadel/zitadel/pkg/grpc/user/v2" + "golang.org/x/text/language" "google.golang.org/grpc/codes" "net/http" "path" "testing" + "time" ) var ( //go:embed testdata/users_create_test_minimal.json minimalUserJson []byte + //go:embed testdata/users_create_test_minimal_inactive.json + minimalInactiveUserJson []byte + //go:embed testdata/users_create_test_full.json fullUserJson []byte @@ -53,6 +60,7 @@ func TestCreateUser(t *testing.T) { name string body []byte ctx context.Context + want *resources.ScimUser wantErr bool scimErrorType string errorStatus int @@ -61,10 +69,127 @@ func TestCreateUser(t *testing.T) { { name: "minimal user", body: minimalUserJson, + want: &resources.ScimUser{ + UserName: "acmeUser1", + Name: &resources.ScimUserName{ + FamilyName: "Ross", + GivenName: "Bethany", + }, + Emails: []*resources.ScimEmail{ + { + Value: "user1@example.com", + Primary: true, + }, + }, + }, + }, + { + name: "minimal inactive user", + body: minimalInactiveUserJson, + want: &resources.ScimUser{ + Active: gu.Ptr(false), + }, }, { name: "full user", body: fullUserJson, + want: &resources.ScimUser{ + ExternalID: "701984", + UserName: "bjensen@example.com", + Name: &resources.ScimUserName{ + Formatted: "Babs Jensen", // DisplayName takes precedence in Zitadel + FamilyName: "Jensen", + GivenName: "Barbara", + MiddleName: "Jane", + HonorificPrefix: "Ms.", + HonorificSuffix: "III", + }, + DisplayName: "Babs Jensen", + NickName: "Babs", + ProfileUrl: integration.Must(schemas.ParseHTTPURL("http://login.example.com/bjensen")), + Emails: []*resources.ScimEmail{ + { + Value: "bjensen@example.com", + Primary: true, + }, + }, + Addresses: []*resources.ScimAddress{ + { + Type: "work", + StreetAddress: "100 Universal City Plaza", + Locality: "Hollywood", + Region: "CA", + PostalCode: "91608", + Country: "USA", + Formatted: "100 Universal City Plaza\nHollywood, CA 91608 USA", + Primary: true, + }, + { + Type: "home", + StreetAddress: "456 Hollywood Blvd", + Locality: "Hollywood", + Region: "CA", + PostalCode: "91608", + Country: "USA", + Formatted: "456 Hollywood Blvd\nHollywood, CA 91608 USA", + }, + }, + PhoneNumbers: []*resources.ScimPhoneNumber{ + { + Value: "+415555555555", + Primary: true, + }, + }, + Ims: []*resources.ScimIms{ + { + Value: "someaimhandle", + Type: "aim", + }, + { + Value: "twitterhandle", + Type: "X", + }, + }, + Photos: []*resources.ScimPhoto{ + { + Value: *integration.Must(schemas.ParseHTTPURL("https://photos.example.com/profilephoto/72930000000Ccne/F")), + Type: "photo", + }, + }, + Roles: []*resources.ScimRole{ + { + Value: "my-role-1", + Display: "Rolle 1", + Type: "main-role", + Primary: true, + }, + { + Value: "my-role-2", + Display: "Rolle 2", + Type: "secondary-role", + Primary: false, + }, + }, + Entitlements: []*resources.ScimEntitlement{ + { + Value: "my-entitlement-1", + Display: "Entitlement 1", + Type: "main-entitlement", + Primary: true, + }, + { + Value: "my-entitlement-2", + Display: "Entitlement 2", + Type: "secondary-entitlement", + Primary: false, + }, + }, + Title: "Tour Guide", + PreferredLanguage: language.MustParse("en-US"), + Locale: "en-US", + Timezone: "America/Los_Angeles", + Active: gu.Ptr(true), + }, }, { name: "missing userName", @@ -152,13 +277,31 @@ func TestCreateUser(t *testing.T) { } assert.NotEmpty(t, createdUser.ID) + defer func() { + _, err = Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: createdUser.ID}) + assert.NoError(t, err) + }() + assert.EqualValues(t, []schemas.ScimSchemaType{"urn:ietf:params:scim:schemas:core:2.0:User"}, createdUser.Resource.Schemas) assert.Equal(t, schemas.ScimResourceTypeSingular("User"), createdUser.Resource.Meta.ResourceType) assert.Equal(t, "http://"+Instance.Host()+path.Join(schemas.HandlerPrefix, Instance.DefaultOrg.Id, "Users", createdUser.ID), createdUser.Resource.Meta.Location) assert.Nil(t, createdUser.Password) - _, err = Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: createdUser.ID}) - assert.NoError(t, err) + if tt.want != nil { + if !integration.PartiallyDeepEqual(tt.want, createdUser) { + t.Errorf("CreateUser() got = %v, want %v", createdUser, tt.want) + } + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Minute) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + // ensure the user is really stored and not just returned to the caller + fetchedUser, err := Instance.Client.SCIM.Users.Get(CTX, Instance.DefaultOrg.Id, createdUser.ID) + require.NoError(ttt, err) + if !integration.PartiallyDeepEqual(tt.want, fetchedUser) { + ttt.Errorf("GetUser() got = %v, want %v", fetchedUser, tt.want) + } + }, retryDuration, tick) + } }) } } @@ -179,32 +322,37 @@ func TestCreateUser_metadata(t *testing.T) { createdUser, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, fullUserJson) require.NoError(t, err) - md, err := Instance.Client.Mgmt.ListUserMetadata(CTX, &management.ListUserMetadataRequest{ - Id: createdUser.ID, - }) - require.NoError(t, err) + defer func() { + _, err = Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: createdUser.ID}) + require.NoError(t, err) + }() - mdMap := make(map[string]string) - for i := range md.Result { - mdMap[md.Result[i].Key] = string(md.Result[i].Value) - } + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Minute) + require.EventuallyWithT(t, func(tt *assert.CollectT) { + md, err := Instance.Client.Mgmt.ListUserMetadata(CTX, &management.ListUserMetadataRequest{ + Id: createdUser.ID, + }) + require.NoError(tt, err) - integration.AssertMapContains(t, mdMap, "urn:zitadel:scim:name.honorificPrefix", "Ms.") - integration.AssertMapContains(t, mdMap, "urn:zitadel:scim:timezone", "America/Los_Angeles") - integration.AssertMapContains(t, mdMap, "urn:zitadel:scim:photos", `[{"value":"https://photos.example.com/profilephoto/72930000000Ccne/F","type":"photo"},{"value":"https://photos.example.com/profilephoto/72930000000Ccne/T","type":"thumbnail"}]`) - integration.AssertMapContains(t, mdMap, "urn:zitadel:scim:addresses", `[{"type":"work","streetAddress":"100 Universal City Plaza","locality":"Hollywood","region":"CA","postalCode":"91608","country":"USA","formatted":"100 Universal City Plaza\nHollywood, CA 91608 USA","primary":true},{"type":"home","streetAddress":"456 Hollywood Blvd","locality":"Hollywood","region":"CA","postalCode":"91608","country":"USA","formatted":"456 Hollywood Blvd\nHollywood, CA 91608 USA"}]`) - integration.AssertMapContains(t, mdMap, "urn:zitadel:scim:entitlements", `[{"value":"my-entitlement-1","display":"Entitlement 1","type":"main-entitlement","primary":true},{"value":"my-entitlement-2","display":"Entitlement 2","type":"secondary-entitlement"}]`) - integration.AssertMapContains(t, mdMap, "urn:zitadel:scim:externalId", "701984") - integration.AssertMapContains(t, mdMap, "urn:zitadel:scim:name.middleName", "Jane") - integration.AssertMapContains(t, mdMap, "urn:zitadel:scim:name.honorificSuffix", "III") - integration.AssertMapContains(t, mdMap, "urn:zitadel:scim:profileURL", "http://login.example.com/bjensen") - integration.AssertMapContains(t, mdMap, "urn:zitadel:scim:title", "Tour Guide") - integration.AssertMapContains(t, mdMap, "urn:zitadel:scim:locale", "en-US") - integration.AssertMapContains(t, mdMap, "urn:zitadel:scim:ims", `[{"value":"someaimhandle","type":"aim"},{"value":"twitterhandle","type":"X"}]`) - integration.AssertMapContains(t, mdMap, "urn:zitadel:scim:roles", `[{"value":"my-role-1","display":"Rolle 1","type":"main-role","primary":true},{"value":"my-role-2","display":"Rolle 2","type":"secondary-role"}]`) + mdMap := make(map[string]string) + for i := range md.Result { + mdMap[md.Result[i].Key] = string(md.Result[i].Value) + } - _, err = Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: createdUser.ID}) - require.NoError(t, err) + integration.AssertMapContains(tt, mdMap, "urn:zitadel:scim:name.honorificPrefix", "Ms.") + integration.AssertMapContains(tt, mdMap, "urn:zitadel:scim:timezone", "America/Los_Angeles") + integration.AssertMapContains(tt, mdMap, "urn:zitadel:scim:photos", `[{"value":"https://photos.example.com/profilephoto/72930000000Ccne/F","type":"photo"},{"value":"https://photos.example.com/profilephoto/72930000000Ccne/T","type":"thumbnail"}]`) + integration.AssertMapContains(tt, mdMap, "urn:zitadel:scim:addresses", `[{"type":"work","streetAddress":"100 Universal City Plaza","locality":"Hollywood","region":"CA","postalCode":"91608","country":"USA","formatted":"100 Universal City Plaza\nHollywood, CA 91608 USA","primary":true},{"type":"home","streetAddress":"456 Hollywood Blvd","locality":"Hollywood","region":"CA","postalCode":"91608","country":"USA","formatted":"456 Hollywood Blvd\nHollywood, CA 91608 USA"}]`) + integration.AssertMapContains(tt, mdMap, "urn:zitadel:scim:entitlements", `[{"value":"my-entitlement-1","display":"Entitlement 1","type":"main-entitlement","primary":true},{"value":"my-entitlement-2","display":"Entitlement 2","type":"secondary-entitlement"}]`) + integration.AssertMapContains(tt, mdMap, "urn:zitadel:scim:externalId", "701984") + integration.AssertMapContains(tt, mdMap, "urn:zitadel:scim:name.middleName", "Jane") + integration.AssertMapContains(tt, mdMap, "urn:zitadel:scim:name.honorificSuffix", "III") + integration.AssertMapContains(tt, mdMap, "urn:zitadel:scim:profileURL", "http://login.example.com/bjensen") + integration.AssertMapContains(tt, mdMap, "urn:zitadel:scim:title", "Tour Guide") + integration.AssertMapContains(tt, mdMap, "urn:zitadel:scim:locale", "en-US") + integration.AssertMapContains(tt, mdMap, "urn:zitadel:scim:ims", `[{"value":"someaimhandle","type":"aim"},{"value":"twitterhandle","type":"X"}]`) + integration.AssertMapContains(tt, mdMap, "urn:zitadel:scim:roles", `[{"value":"my-role-1","display":"Rolle 1","type":"main-role","primary":true},{"value":"my-role-2","display":"Rolle 2","type":"secondary-role"}]`) + }, retryDuration, tick) } func TestCreateUser_scopedExternalID(t *testing.T) { @@ -218,23 +366,34 @@ func TestCreateUser_scopedExternalID(t *testing.T) { createdUser, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, fullUserJson) require.NoError(t, err) - // unscoped externalID should not exist - _, err = Instance.Client.Mgmt.GetUserMetadata(CTX, &management.GetUserMetadataRequest{ - Id: createdUser.ID, - Key: "urn:zitadel:scim:externalId", - }) - integration.AssertGrpcStatus(t, codes.NotFound, err) + defer func() { + _, err = Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: createdUser.ID}) + require.NoError(t, err) - // scoped externalID should exist - md, err := Instance.Client.Mgmt.GetUserMetadata(CTX, &management.GetUserMetadataRequest{ - Id: createdUser.ID, - Key: "urn:zitadel:scim:fooBar:externalId", - }) - require.NoError(t, err) - assert.Equal(t, "701984", string(md.Metadata.Value)) + _, err = Instance.Client.Mgmt.RemoveUserMetadata(CTX, &management.RemoveUserMetadataRequest{ + Id: Instance.Users.Get(integration.UserTypeOrgOwner).ID, + Key: "urn:zitadel:scim:provisioning_domain", + }) + require.NoError(t, err) + }() - _, err = Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: createdUser.ID}) - require.NoError(t, err) + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Minute) + require.EventuallyWithT(t, func(tt *assert.CollectT) { + // unscoped externalID should not exist + _, err = Instance.Client.Mgmt.GetUserMetadata(CTX, &management.GetUserMetadataRequest{ + Id: createdUser.ID, + Key: "urn:zitadel:scim:externalId", + }) + integration.AssertGrpcStatus(tt, codes.NotFound, err) + + // scoped externalID should exist + md, err := Instance.Client.Mgmt.GetUserMetadata(CTX, &management.GetUserMetadataRequest{ + Id: createdUser.ID, + Key: "urn:zitadel:scim:fooBar:externalId", + }) + require.NoError(tt, err) + assert.Equal(tt, "701984", string(md.Metadata.Value)) + }, retryDuration, tick) } func TestCreateUser_anotherOrg(t *testing.T) { diff --git a/internal/api/scim/integration_test/users_delete_test.go b/internal/api/scim/integration_test/users_delete_test.go index 6d3f73a71e..88c7bf88ef 100644 --- a/internal/api/scim/integration_test/users_delete_test.go +++ b/internal/api/scim/integration_test/users_delete_test.go @@ -13,6 +13,7 @@ import ( "google.golang.org/grpc/codes" "net/http" "testing" + "time" ) func TestDeleteUser_errors(t *testing.T) { @@ -71,9 +72,12 @@ func TestDeleteUser_ensureReallyDeleted(t *testing.T) { err = Instance.Client.SCIM.Users.Delete(CTX, Instance.DefaultOrg.Id, createUserResp.UserId) scim.RequireScimError(t, http.StatusNotFound, err) - // try to get user via api => should 404 - _, err = Instance.Client.UserV2.GetUserByID(CTX, &user.GetUserByIDRequest{UserId: createUserResp.UserId}) - integration.AssertGrpcStatus(t, codes.NotFound, err) + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Minute) + require.EventuallyWithT(t, func(tt *assert.CollectT) { + // try to get user via api => should 404 + _, err = Instance.Client.UserV2.GetUserByID(CTX, &user.GetUserByIDRequest{UserId: createUserResp.UserId}) + integration.AssertGrpcStatus(tt, codes.NotFound, err) + }, retryDuration, tick) } func TestDeleteUser_anotherOrg(t *testing.T) { diff --git a/internal/api/scim/integration_test/users_get_test.go b/internal/api/scim/integration_test/users_get_test.go index 4506de1ecf..0790b591c7 100644 --- a/internal/api/scim/integration_test/users_get_test.go +++ b/internal/api/scim/integration_test/users_get_test.go @@ -13,17 +13,19 @@ import ( "github.com/zitadel/zitadel/internal/integration" "github.com/zitadel/zitadel/internal/integration/scim" "github.com/zitadel/zitadel/pkg/grpc/management" - guser "github.com/zitadel/zitadel/pkg/grpc/user/v2" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" "golang.org/x/text/language" "net/http" "path" "testing" + "time" ) func TestGetUser(t *testing.T) { tests := []struct { name string - buildUserID func() (userID string, deleteUser bool) + buildUserID func() string + cleanup func(userID string) ctx context.Context want *resources.ScimUser wantErr bool @@ -43,8 +45,8 @@ func TestGetUser(t *testing.T) { }, { name: "unknown user id", - buildUserID: func() (string, bool) { - return "unknown", false + buildUserID: func() string { + return "unknown" }, errorStatus: http.StatusNotFound, wantErr: true, @@ -67,10 +69,14 @@ func TestGetUser(t *testing.T) { }, { name: "created via scim", - buildUserID: func() (string, bool) { - user, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, fullUserJson) + buildUserID: func() string { + createdUser, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, fullUserJson) + require.NoError(t, err) + return createdUser.ID + }, + cleanup: func(userID string) { + _, err := Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: userID}) require.NoError(t, err) - return user.ID, true }, want: &resources.ScimUser{ ExternalID: "701984", @@ -176,9 +182,9 @@ func TestGetUser(t *testing.T) { }, { name: "scoped externalID", - buildUserID: func() (string, bool) { + buildUserID: func() string { // create user without provisioning domain - user, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, fullUserJson) + createdUser, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, fullUserJson) require.NoError(t, err) // set provisioning domain of service user @@ -191,12 +197,22 @@ func TestGetUser(t *testing.T) { // set externalID for provisioning domain _, err = Instance.Client.Mgmt.SetUserMetadata(CTX, &management.SetUserMetadataRequest{ - Id: user.ID, + Id: createdUser.ID, Key: "urn:zitadel:scim:fooBar:externalId", Value: []byte("100-scopedExternalId"), }) require.NoError(t, err) - return user.ID, true + return createdUser.ID + }, + cleanup: func(userID string) { + _, err := Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: userID}) + require.NoError(t, err) + + _, err = Instance.Client.Mgmt.RemoveUserMetadata(CTX, &management.RemoveUserMetadataRequest{ + Id: Instance.Users.Get(integration.UserTypeOrgOwner).ID, + Key: "urn:zitadel:scim:provisioning_domain", + }) + require.NoError(t, err) }, want: &resources.ScimUser{ ExternalID: "100-scopedExternalId", @@ -211,37 +227,40 @@ func TestGetUser(t *testing.T) { } var userID string - var deleteUserAfterTest bool if tt.buildUserID != nil { - userID, deleteUserAfterTest = tt.buildUserID() + userID = tt.buildUserID() } else { createUserResp := Instance.CreateHumanUser(CTX) userID = createUserResp.UserId } - user, err := Instance.Client.SCIM.Users.Get(ctx, Instance.DefaultOrg.Id, userID) - if tt.wantErr { - statusCode := tt.errorStatus - if statusCode == 0 { - statusCode = http.StatusBadRequest + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Minute) + var fetchedUser *resources.ScimUser + var err error + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + fetchedUser, err = Instance.Client.SCIM.Users.Get(ctx, Instance.DefaultOrg.Id, userID) + if tt.wantErr { + statusCode := tt.errorStatus + if statusCode == 0 { + statusCode = http.StatusBadRequest + } + + scim.RequireScimError(ttt, statusCode, err) + return } - scim.RequireScimError(t, statusCode, err) - return - } + assert.Equal(ttt, userID, fetchedUser.ID) + assert.EqualValues(ttt, []schemas.ScimSchemaType{"urn:ietf:params:scim:schemas:core:2.0:User"}, fetchedUser.Schemas) + assert.Equal(ttt, schemas.ScimResourceTypeSingular("User"), fetchedUser.Resource.Meta.ResourceType) + assert.Equal(ttt, "http://"+Instance.Host()+path.Join(schemas.HandlerPrefix, Instance.DefaultOrg.Id, "Users", fetchedUser.ID), fetchedUser.Resource.Meta.Location) + assert.Nil(ttt, fetchedUser.Password) + if !integration.PartiallyDeepEqual(tt.want, fetchedUser) { + ttt.Errorf("GetUser() got = %#v, want %#v", fetchedUser, tt.want) + } + }, retryDuration, tick) - assert.Equal(t, userID, user.ID) - assert.EqualValues(t, []schemas.ScimSchemaType{"urn:ietf:params:scim:schemas:core:2.0:User"}, user.Schemas) - assert.Equal(t, schemas.ScimResourceTypeSingular("User"), user.Resource.Meta.ResourceType) - assert.Equal(t, "http://"+Instance.Host()+path.Join(schemas.HandlerPrefix, Instance.DefaultOrg.Id, "Users", user.ID), user.Resource.Meta.Location) - assert.Nil(t, user.Password) - if !integration.PartiallyDeepEqual(tt.want, user) { - t.Errorf("keysFromArgs() got = %v, want %v", user, tt.want) - } - - if deleteUserAfterTest { - _, err = Instance.Client.UserV2.DeleteUser(CTX, &guser.DeleteUserRequest{UserId: user.ID}) - require.NoError(t, err) + if tt.cleanup != nil { + tt.cleanup(fetchedUser.ID) } }) } diff --git a/internal/api/scim/integration_test/users_replace_test.go b/internal/api/scim/integration_test/users_replace_test.go new file mode 100644 index 0000000000..664364bbee --- /dev/null +++ b/internal/api/scim/integration_test/users_replace_test.go @@ -0,0 +1,329 @@ +//go:build integration + +package integration_test + +import ( + "context" + _ "embed" + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zitadel/zitadel/internal/api/scim/resources" + "github.com/zitadel/zitadel/internal/api/scim/schemas" + "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/internal/integration/scim" + "github.com/zitadel/zitadel/pkg/grpc/management" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" + "golang.org/x/text/language" + "net/http" + "path" + "testing" + "time" +) + +var ( + //go:embed testdata/users_replace_test_minimal_with_external_id.json + minimalUserWithExternalIDJson []byte + + //go:embed testdata/users_replace_test_minimal.json + minimalUserReplaceJson []byte + + //go:embed testdata/users_replace_test_full.json + fullUserReplaceJson []byte +) + +func TestReplaceUser(t *testing.T) { + tests := []struct { + name string + body []byte + ctx context.Context + want *resources.ScimUser + wantErr bool + scimErrorType string + errorStatus int + zitadelErrID string + }{ + { + name: "minimal user", + body: minimalUserReplaceJson, + want: &resources.ScimUser{ + UserName: "acmeUser1-minimal-replaced", + Name: &resources.ScimUserName{ + FamilyName: "Ross-replaced", + GivenName: "Bethany-replaced", + }, + Emails: []*resources.ScimEmail{ + { + Value: "user1-minimal-replaced@example.com", + Primary: true, + }, + }, + }, + }, + { + name: "full user", + body: fullUserReplaceJson, + want: &resources.ScimUser{ + ExternalID: "701984-updated", + UserName: "bjensen-replaced-full@example.com", + Name: &resources.ScimUserName{ + Formatted: "Babs Jensen-updated", // display name takes precedence + FamilyName: "Jensen-updated", + GivenName: "Barbara-updated", + MiddleName: "Jane-updated", + HonorificPrefix: "Ms.-updated", + HonorificSuffix: "III", + }, + DisplayName: "Babs Jensen-updated", + NickName: "Babs-updated", + ProfileUrl: integration.Must(schemas.ParseHTTPURL("http://login.example.com/bjensen-updated")), + Emails: []*resources.ScimEmail{ + { + Value: "bjensen-replaced-full@example.com", + Primary: true, + }, + }, + Addresses: []*resources.ScimAddress{ + { + Type: "work-updated", + StreetAddress: "100 Universal City Plaza-updated", + Locality: "Hollywood-updated", + Region: "CA-updated", + PostalCode: "91608-updated", + Country: "USA-updated", + Formatted: "100 Universal City Plaza\nHollywood, CA 91608 USA-updated", + Primary: true, + }, + { + Type: "home-updated", + StreetAddress: "456 Hollywood Blvd-updated", + Locality: "Hollywood-updated", + Region: "CA-updated", + PostalCode: "91608-updated", + Country: "USA-updated", + Formatted: "456 Hollywood Blvd\nHollywood, CA 91608 USA-updated", + }, + }, + PhoneNumbers: []*resources.ScimPhoneNumber{ + { + Value: "+4155555555558732833", + Primary: true, + }, + }, + Ims: []*resources.ScimIms{ + { + Value: "someaimhandle-updated", + Type: "aim-updated", + }, + { + Value: "twitterhandle-updated", + Type: "X-updated", + }, + }, + Photos: []*resources.ScimPhoto{ + { + Value: *integration.Must(schemas.ParseHTTPURL("https://photos.example.com/profilephoto/72930000000Ccne/F-updated")), + Type: "photo-updated", + }, + { + Value: *integration.Must(schemas.ParseHTTPURL("https://photos.example.com/profilephoto/72930000000Ccne/T-updated")), + Type: "thumbnail-updated", + }, + }, + Roles: []*resources.ScimRole{ + { + Value: "my-role-1-updated", + Display: "Rolle 1-updated", + Type: "main-role-updated", + Primary: true, + }, + { + Value: "my-role-2-updated", + Display: "Rolle 2-updated", + Type: "secondary-role-updated", + Primary: false, + }, + }, + Entitlements: []*resources.ScimEntitlement{ + { + Value: "my-entitlement-1-updated", + Display: "Entitlement 1-updated", + Type: "main-entitlement-updated", + Primary: true, + }, + { + Value: "my-entitlement-2-updated", + Display: "Entitlement 2-updated", + Type: "secondary-entitlement-updated", + Primary: false, + }, + }, + Title: "Tour Guide-updated", + PreferredLanguage: language.MustParse("en-CH"), + Locale: "en-CH", + Timezone: "Europe/Zurich", + Active: gu.Ptr(false), + }, + }, + { + name: "password complexity violation", + wantErr: true, + scimErrorType: "invalidValue", + body: invalidPasswordUserJson, + }, + { + name: "invalid profile url", + wantErr: true, + scimErrorType: "invalidValue", + zitadelErrID: "SCIM-htturl1", + body: invalidProfileUrlUserJson, + }, + { + name: "invalid time zone", + wantErr: true, + scimErrorType: "invalidValue", + body: invalidTimeZoneUserJson, + }, + { + name: "invalid locale", + wantErr: true, + scimErrorType: "invalidValue", + body: invalidLocaleUserJson, + }, + { + name: "not authenticated", + body: minimalUserJson, + ctx: context.Background(), + wantErr: true, + errorStatus: http.StatusUnauthorized, + }, + { + name: "no permissions", + body: minimalUserJson, + ctx: Instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + wantErr: true, + errorStatus: http.StatusNotFound, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + createdUser, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, fullUserJson) + require.NoError(t, err) + + defer func() { + _, err = Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: createdUser.ID}) + assert.NoError(t, err) + }() + + ctx := tt.ctx + if ctx == nil { + ctx = CTX + } + + replacedUser, err := Instance.Client.SCIM.Users.Replace(ctx, Instance.DefaultOrg.Id, createdUser.ID, tt.body) + if (err != nil) != tt.wantErr { + t.Errorf("ReplaceUser() error = %v, wantErr %v", err, tt.wantErr) + } + + if err != nil { + statusCode := tt.errorStatus + if statusCode == 0 { + statusCode = http.StatusBadRequest + } + scimErr := scim.RequireScimError(t, statusCode, err) + assert.Equal(t, tt.scimErrorType, scimErr.Error.ScimType) + if tt.zitadelErrID != "" { + assert.Equal(t, tt.zitadelErrID, scimErr.Error.ZitadelDetail.ID) + } + + return + } + + assert.NotEmpty(t, replacedUser.ID) + assert.EqualValues(t, []schemas.ScimSchemaType{"urn:ietf:params:scim:schemas:core:2.0:User"}, replacedUser.Resource.Schemas) + assert.Equal(t, schemas.ScimResourceTypeSingular("User"), replacedUser.Resource.Meta.ResourceType) + assert.Equal(t, "http://"+Instance.Host()+path.Join(schemas.HandlerPrefix, Instance.DefaultOrg.Id, "Users", createdUser.ID), replacedUser.Resource.Meta.Location) + assert.Nil(t, createdUser.Password) + + if !integration.PartiallyDeepEqual(tt.want, replacedUser) { + t.Errorf("ReplaceUser() got = %#v, want %#v", replacedUser, tt.want) + } + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Minute) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + // ensure the user is really stored and not just returned to the caller + fetchedUser, err := Instance.Client.SCIM.Users.Get(CTX, Instance.DefaultOrg.Id, replacedUser.ID) + require.NoError(ttt, err) + if !integration.PartiallyDeepEqual(tt.want, fetchedUser) { + ttt.Errorf("GetUser() got = %#v, want %#v", fetchedUser, tt.want) + } + }, retryDuration, tick) + }) + } + +} + +func TestReplaceUser_removeOldMetadata(t *testing.T) { + // ensure old metadata is removed correctly + createdUser, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, fullUserJson) + require.NoError(t, err) + + _, err = Instance.Client.SCIM.Users.Replace(CTX, Instance.DefaultOrg.Id, createdUser.ID, minimalUserJson) + require.NoError(t, err) + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Minute) + require.EventuallyWithT(t, func(tt *assert.CollectT) { + md, err := Instance.Client.Mgmt.ListUserMetadata(CTX, &management.ListUserMetadataRequest{ + Id: createdUser.ID, + }) + require.NoError(tt, err) + require.Equal(tt, 0, len(md.Result)) + }, retryDuration, tick) + + _, err = Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: createdUser.ID}) + require.NoError(t, err) +} + +func TestReplaceUser_scopedExternalID(t *testing.T) { + // create user without provisioning domain set + createdUser, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, fullUserJson) + require.NoError(t, err) + + // set provisioning domain of service user + _, err = Instance.Client.Mgmt.SetUserMetadata(CTX, &management.SetUserMetadataRequest{ + Id: Instance.Users.Get(integration.UserTypeOrgOwner).ID, + Key: "urn:zitadel:scim:provisioning_domain", + Value: []byte("fooBazz"), + }) + require.NoError(t, err) + + // replace the user with provisioning domain set + _, err = Instance.Client.SCIM.Users.Replace(CTX, Instance.DefaultOrg.Id, createdUser.ID, minimalUserWithExternalIDJson) + require.NoError(t, err) + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Minute) + require.EventuallyWithT(t, func(tt *assert.CollectT) { + md, err := Instance.Client.Mgmt.ListUserMetadata(CTX, &management.ListUserMetadataRequest{ + Id: createdUser.ID, + }) + require.NoError(tt, err) + + mdMap := make(map[string]string) + for i := range md.Result { + mdMap[md.Result[i].Key] = string(md.Result[i].Value) + } + + // both external IDs should be present on the user + integration.AssertMapContains(tt, mdMap, "urn:zitadel:scim:externalId", "701984") + integration.AssertMapContains(tt, mdMap, "urn:zitadel:scim:fooBazz:externalId", "replaced-external-id") + }, retryDuration, tick) + + _, err = Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: createdUser.ID}) + require.NoError(t, err) + + _, err = Instance.Client.Mgmt.RemoveUserMetadata(CTX, &management.RemoveUserMetadataRequest{ + Id: Instance.Users.Get(integration.UserTypeOrgOwner).ID, + Key: "urn:zitadel:scim:provisioning_domain", + }) + require.NoError(t, err) +} diff --git a/internal/api/scim/resources/resource_handler.go b/internal/api/scim/resources/resource_handler.go index 93cb9adca0..4e1d9c1d4a 100644 --- a/internal/api/scim/resources/resource_handler.go +++ b/internal/api/scim/resources/resource_handler.go @@ -19,6 +19,7 @@ type ResourceHandler[T ResourceHolder] interface { NewResource() T Create(ctx context.Context, resource T) (T, error) + Replace(ctx context.Context, id string, resource T) (T, error) Delete(ctx context.Context, id string) error Get(ctx context.Context, id string) (T, error) } diff --git a/internal/api/scim/resources/resource_handler_adapter.go b/internal/api/scim/resources/resource_handler_adapter.go index bb362e9dfd..5a346911af 100644 --- a/internal/api/scim/resources/resource_handler_adapter.go +++ b/internal/api/scim/resources/resource_handler_adapter.go @@ -47,6 +47,16 @@ func (adapter *ResourceHandlerAdapter[T]) Create(r *http.Request) (T, error) { return adapter.handler.Create(r.Context(), entity) } +func (adapter *ResourceHandlerAdapter[T]) Replace(r *http.Request) (T, error) { + entity, err := adapter.readEntityFromBody(r) + if err != nil { + return entity, err + } + + id := mux.Vars(r)["id"] + return adapter.handler.Replace(r.Context(), id, entity) +} + func (adapter *ResourceHandlerAdapter[T]) Delete(r *http.Request) error { id := mux.Vars(r)["id"] return adapter.handler.Delete(r.Context(), id) diff --git a/internal/api/scim/resources/user.go b/internal/api/scim/resources/user.go index 5c9b35263f..defe849538 100644 --- a/internal/api/scim/resources/user.go +++ b/internal/api/scim/resources/user.go @@ -140,8 +140,23 @@ func (h *UsersHandler) Create(ctx context.Context, user *ScimUser) (*ScimUser, e return nil, err } - user.ID = addHuman.Details.ID - user.Resource = buildResource(ctx, h, addHuman.Details) + h.mapAddCommandToScimUser(ctx, user, addHuman) + return user, nil +} + +func (h *UsersHandler) Replace(ctx context.Context, id string, user *ScimUser) (*ScimUser, error) { + user.ID = id + changeHuman, err := h.mapToChangeHuman(ctx, user) + if err != nil { + return nil, err + } + + err = h.command.ChangeUserHuman(ctx, changeHuman, h.userCodeAlg) + if err != nil { + return nil, err + } + + h.mapChangeCommandToScimUser(ctx, user, changeHuman) return user, nil } diff --git a/internal/api/scim/resources/user_mapping.go b/internal/api/scim/resources/user_mapping.go index 9726f1e190..4de826ca69 100644 --- a/internal/api/scim/resources/user_mapping.go +++ b/internal/api/scim/resources/user_mapping.go @@ -17,14 +17,22 @@ import ( ) func (h *UsersHandler) mapToAddHuman(ctx context.Context, scimUser *ScimUser) (*command.AddHuman, error) { - // zitadel has its own state mechanism - // ignore scimUser.Active human := &command.AddHuman{ Username: scimUser.UserName, NickName: scimUser.NickName, DisplayName: scimUser.DisplayName, - Email: h.mapPrimaryEmail(scimUser), - Phone: h.mapPrimaryPhone(scimUser), + } + + if scimUser.Active != nil && !*scimUser.Active { + human.SetInactive = true + } + + if email := h.mapPrimaryEmail(scimUser); email != nil { + human.Email = *email + } + + if phone := h.mapPrimaryPhone(scimUser); phone != nil { + human.Phone = *phone } md, err := h.mapMetadataToCommands(ctx, scimUser) @@ -46,6 +54,9 @@ func (h *UsersHandler) mapToAddHuman(ctx context.Context, scimUser *ScimUser) (* // over the formatted name assignment if human.DisplayName == "" { human.DisplayName = scimUser.Name.Formatted + } else { + // update user to match the actual stored value + scimUser.Name.Formatted = human.DisplayName } } @@ -57,34 +68,144 @@ func (h *UsersHandler) mapToAddHuman(ctx context.Context, scimUser *ScimUser) (* return human, nil } -func (h *UsersHandler) mapPrimaryEmail(scimUser *ScimUser) command.Email { +func (h *UsersHandler) mapToChangeHuman(ctx context.Context, scimUser *ScimUser) (*command.ChangeHuman, error) { + human := &command.ChangeHuman{ + ID: scimUser.ID, + Username: &scimUser.UserName, + Profile: &command.Profile{ + NickName: &scimUser.NickName, + DisplayName: &scimUser.DisplayName, + }, + Email: h.mapPrimaryEmail(scimUser), + Phone: h.mapPrimaryPhone(scimUser), + } + + if scimUser.Active != nil { + if *scimUser.Active { + human.State = gu.Ptr(domain.UserStateActive) + } else { + human.State = gu.Ptr(domain.UserStateInactive) + } + } + + md, mdRemovedKeys, err := h.mapMetadataToDomain(ctx, scimUser) + if err != nil { + return nil, err + } + human.Metadata = md + human.MetadataKeysToRemove = mdRemovedKeys + + if scimUser.Password != nil { + human.Password = &command.Password{ + Password: scimUser.Password.String(), + } + scimUser.Password = nil + } + + if scimUser.Name != nil { + human.Profile.FirstName = &scimUser.Name.GivenName + human.Profile.LastName = &scimUser.Name.FamilyName + + // the direct mapping displayName => displayName has priority + // over the formatted name assignment + if *human.Profile.DisplayName == "" { + human.Profile.DisplayName = &scimUser.Name.Formatted + } else { + // update user to match the actual stored value + scimUser.Name.Formatted = *human.Profile.DisplayName + } + } + + if err := domain.LanguageIsDefined(scimUser.PreferredLanguage); err != nil { + human.Profile.PreferredLanguage = &language.English + scimUser.PreferredLanguage = language.English + } + + return human, nil +} + +func (h *UsersHandler) mapPrimaryEmail(scimUser *ScimUser) *command.Email { for _, email := range scimUser.Emails { if !email.Primary { continue } - return command.Email{ + return &command.Email{ Address: domain.EmailAddress(email.Value), Verified: h.config.EmailVerified, } } - return command.Email{} + return nil } -func (h *UsersHandler) mapPrimaryPhone(scimUser *ScimUser) command.Phone { +func (h *UsersHandler) mapPrimaryPhone(scimUser *ScimUser) *command.Phone { for _, phone := range scimUser.PhoneNumbers { if !phone.Primary { continue } - return command.Phone{ + return &command.Phone{ Number: domain.PhoneNumber(phone.Value), Verified: h.config.PhoneVerified, } } - return command.Phone{} + return nil +} + +func (h *UsersHandler) mapAddCommandToScimUser(ctx context.Context, user *ScimUser, addHuman *command.AddHuman) { + user.ID = addHuman.Details.ID + user.Resource = buildResource(ctx, h, addHuman.Details) + user.Password = nil + + // ZITADEL supports only one (primary) phone number or email. + // Therefore, only the primary one should be returned. + // Note that the phone number might also be reformatted. + if addHuman.Phone.Number != "" { + user.PhoneNumbers = []*ScimPhoneNumber{ + { + Value: string(addHuman.Phone.Number), + Primary: true, + }, + } + } + + if addHuman.Email.Address != "" { + user.Emails = []*ScimEmail{ + { + Value: string(addHuman.Email.Address), + Primary: true, + }, + } + } +} + +func (h *UsersHandler) mapChangeCommandToScimUser(ctx context.Context, user *ScimUser, changeHuman *command.ChangeHuman) { + user.ID = changeHuman.Details.ID + user.Resource = buildResource(ctx, h, changeHuman.Details) + user.Password = nil + + // ZITADEL supports only one (primary) phone number or email. + // Therefore, only the primary one should be returned. + // Note that the phone number might also be reformatted. + if changeHuman.Phone != nil { + user.PhoneNumbers = []*ScimPhoneNumber{ + { + Value: string(changeHuman.Phone.Number), + Primary: true, + }, + } + } + + if changeHuman.Email != nil { + user.Emails = []*ScimEmail{ + { + Value: string(changeHuman.Email.Address), + Primary: true, + }, + } + } } func (h *UsersHandler) mapToScimUser(ctx context.Context, user *query.User, md map[metadata.ScopedKey][]byte) *ScimUser { diff --git a/internal/api/scim/resources/user_metadata.go b/internal/api/scim/resources/user_metadata.go index f094695a27..1bb00ff8a0 100644 --- a/internal/api/scim/resources/user_metadata.go +++ b/internal/api/scim/resources/user_metadata.go @@ -12,6 +12,7 @@ import ( "github.com/zitadel/zitadel/internal/api/scim/schemas" "github.com/zitadel/zitadel/internal/api/scim/serrors" "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/zerrors" ) @@ -55,6 +56,28 @@ func buildMetadataKeyQuery(ctx context.Context, key metadata.Key) query.SearchQu return q } +func (h *UsersHandler) mapMetadataToDomain(ctx context.Context, user *ScimUser) (md []*domain.Metadata, skippedMetadata []string, err error) { + md = make([]*domain.Metadata, 0, len(metadata.ScimUserRelevantMetadataKeys)) + for _, key := range metadata.ScimUserRelevantMetadataKeys { + var value []byte + value, err = getValueForMetadataKey(user, key) + if err != nil { + return + } + + if len(value) > 0 { + md = append(md, &domain.Metadata{ + Key: string(metadata.ScopeKey(ctx, key)), + Value: value, + }) + } else { + skippedMetadata = append(skippedMetadata, string(metadata.ScopeKey(ctx, key))) + } + } + + return +} + func (h *UsersHandler) mapMetadataToCommands(ctx context.Context, user *ScimUser) ([]*command.AddMetadataEntry, error) { md := make([]*command.AddMetadataEntry, 0, len(metadata.ScimUserRelevantMetadataKeys)) for _, key := range metadata.ScimUserRelevantMetadataKeys { diff --git a/internal/api/scim/server.go b/internal/api/scim/server.go index 2a4385e21e..d5d739bdc9 100644 --- a/internal/api/scim/server.go +++ b/internal/api/scim/server.go @@ -55,6 +55,7 @@ func mapResource[T sresources.ResourceHolder](router *mux.Router, mw zhttp_middl resourceRouter.Handle("", mw(handleResourceCreatedResponse(adapter.Create))).Methods(http.MethodPost) resourceRouter.Handle("/{id}", mw(handleResourceResponse(adapter.Get))).Methods(http.MethodGet) + resourceRouter.Handle("/{id}", mw(handleResourceResponse(adapter.Replace))).Methods(http.MethodPut) resourceRouter.Handle("/{id}", mw(handleEmptyResponse(adapter.Delete))).Methods(http.MethodDelete) } diff --git a/internal/command/user_human.go b/internal/command/user_human.go index ab2617c276..9e6ba43629 100644 --- a/internal/command/user_human.go +++ b/internal/command/user_human.go @@ -59,6 +59,8 @@ type AddHuman struct { Passwordless bool ExternalIDP bool Register bool + // SetInactive whether the user initially should be set as inactive + SetInactive bool // UserAgentID is optional and can be passed in case the user registered themselves. // This will be used in the login UI to handle authentication automatically. UserAgentID string diff --git a/internal/command/user_model.go b/internal/command/user_model.go index 0e68f0812c..d600f9d98a 100644 --- a/internal/command/user_model.go +++ b/internal/command/user_model.go @@ -137,6 +137,10 @@ func isUserStateInactive(state domain.UserState) bool { return hasUserState(state, domain.UserStateInactive) } +func isUserStateActive(state domain.UserState) bool { + return hasUserState(state, domain.UserStateActive) +} + func isUserStateInitial(state domain.UserState) bool { return hasUserState(state, domain.UserStateInitial) } diff --git a/internal/command/user_v2_human.go b/internal/command/user_v2_human.go index a85f905e05..fa627ec66e 100644 --- a/internal/command/user_v2_human.go +++ b/internal/command/user_v2_human.go @@ -14,11 +14,14 @@ import ( ) type ChangeHuman struct { - ID string - Username *string - Profile *Profile - Email *Email - Phone *Phone + ID string + State *domain.UserState + Username *string + Profile *Profile + Email *Email + Phone *Phone + Metadata []*domain.Metadata + MetadataKeysToRemove []string Password *Password @@ -100,6 +103,15 @@ func (h *ChangeHuman) Changed() bool { if h.Password != nil { return true } + if h.State != nil { + return true + } + if len(h.Metadata) > 0 { + return true + } + if len(h.MetadataKeysToRemove) > 0 { + return true + } return false } @@ -229,6 +241,10 @@ func (c *Commands) AddUserHuman(ctx context.Context, resourceOwner string, human ) } + if human.SetInactive { + cmds = append(cmds, user.NewUserDeactivatedEvent(ctx, &existingHuman.Aggregate().Aggregate)) + } + if len(cmds) == 0 { human.Details = writeModelToObjectDetails(&existingHuman.WriteModel) return nil @@ -270,6 +286,7 @@ func (c *Commands) ChangeUserHuman(ctx context.Context, human *ChangeHuman, alg } } + userAgg := UserAggregateFromWriteModelCtx(ctx, &existingHuman.WriteModel) cmds := make([]eventstore.Command, 0) if human.Username != nil { cmds, err = c.changeUsername(ctx, cmds, existingHuman, *human.Username) @@ -302,6 +319,58 @@ func (c *Commands) ChangeUserHuman(ctx context.Context, human *ChangeHuman, alg } } + for _, md := range human.Metadata { + cmd, err := c.setUserMetadata(ctx, userAgg, md) + if err != nil { + return err + } + + cmds = append(cmds, cmd) + } + + for _, mdKey := range human.MetadataKeysToRemove { + cmd, err := c.removeUserMetadata(ctx, userAgg, mdKey) + if err != nil { + return err + } + + cmds = append(cmds, cmd) + } + + if human.State != nil { + // only allow toggling between active and inactive + // any other target state is not supported + // the existing human's state has to be the + switch { + case isUserStateActive(*human.State): + if isUserStateActive(existingHuman.UserState) { + // user is already active => no change needed + break + } + + // do not allow switching from other states than active (e.g. locked) + if !isUserStateInactive(existingHuman.UserState) { + return zerrors.ThrowInvalidArgumentf(nil, "USER2-statex1", "Errors.User.State.Invalid") + } + + cmds = append(cmds, user.NewUserReactivatedEvent(ctx, &existingHuman.Aggregate().Aggregate)) + case isUserStateInactive(*human.State): + if isUserStateInactive(existingHuman.UserState) { + // user is already inactive => no change needed + break + } + + // do not allow switching from other states than active (e.g. locked) + if !isUserStateActive(existingHuman.UserState) { + return zerrors.ThrowInvalidArgumentf(nil, "USER2-statex2", "Errors.User.State.Invalid") + } + + cmds = append(cmds, user.NewUserDeactivatedEvent(ctx, &existingHuman.Aggregate().Aggregate)) + default: + return zerrors.ThrowInvalidArgumentf(nil, "USER2-statex3", "Errors.User.State.Invalid") + } + } + if len(cmds) == 0 { human.Details = writeModelToObjectDetails(&existingHuman.WriteModel) return nil diff --git a/internal/integration/assert.go b/internal/integration/assert.go index 40c6815190..de35357dd7 100644 --- a/internal/integration/assert.go +++ b/internal/integration/assert.go @@ -171,9 +171,13 @@ func diffProto(expected, actual proto.Message) string { return "\n\nDiff:\n" + diff } -func AssertMapContains[M ~map[K]V, K comparable, V any](t *testing.T, m M, key K, expectedValue V) { +func AssertMapContains[M ~map[K]V, K comparable, V any](t assert.TestingT, m M, key K, expectedValue V) { val, exists := m[key] assert.True(t, exists, "Key '%s' should exist in the map", key) + if !exists { + return + } + assert.Equal(t, expectedValue, val, "Key '%s' should have value '%d'", key, expectedValue) } @@ -195,7 +199,7 @@ func partiallyDeepEqual(expected, actual reflect.Value) bool { // Dereference pointers if needed if expected.Kind() == reflect.Ptr { if expected.IsNil() { - return actual.IsNil() + return true } expected = expected.Elem() diff --git a/internal/integration/scim/client.go b/internal/integration/scim/client.go index 2a6f106b9e..262835a827 100644 --- a/internal/integration/scim/client.go +++ b/internal/integration/scim/client.go @@ -56,6 +56,10 @@ func (c *ResourceClient[T]) Create(ctx context.Context, orgID string, body []byt return c.doWithBody(ctx, http.MethodPost, orgID, "", bytes.NewReader(body)) } +func (c *ResourceClient[T]) Replace(ctx context.Context, orgID, id string, body []byte) (*T, error) { + return c.doWithBody(ctx, http.MethodPut, orgID, id, bytes.NewReader(body)) +} + func (c *ResourceClient[T]) Get(ctx context.Context, orgID, resourceID string) (*T, error) { return c.doWithBody(ctx, http.MethodGet, orgID, resourceID, nil) } From b664ffe99347d9adf4325095d98684f90767cbca Mon Sep 17 00:00:00 2001 From: MAHANTH-wq <52274109+MAHANTH-wq@users.noreply.github.com> Date: Wed, 15 Jan 2025 14:10:30 +0530 Subject: [PATCH 19/30] feat(/internal): Add User Resource Owner (#9168) Update the ../proto/zitadel/member.proto to include the UserResourceOwner as part of member. Update the queries to include UserResourceOwner for the following : zitadel/internal/query/iam_member.go zitadel/internal/query/org_member.go zitadel/internal/query/project_member.go zitadel/internal/query/project_grant_member.go Non Breaking Changes # Which Problems Are Solved https://github.com/zitadel/zitadel/issues/5062 # How the Problems Are Solved - Updated the member.proto file to include user_resource_owner. I have compiled using` make compile` command . - Changed the queries to include the userResourceOwner as part of Member. - Then, updated the converter to map the userResourceOwner. # Additional Changes Replace this example text with a concise list of additional changes that this PR introduces, that are not directly solving the initial problem but are related. For example: - The docs explicitly describe that the property XY is mandatory - Adds missing translations for validations. # Additional Context - Closes #5062 - https://discordapp.com/channels/927474939156643850/1326245856193544232/1326476710752948316 --- internal/api/grpc/member/converter.go | 1 + internal/query/iam_member.go | 6 ++++++ internal/query/iam_member_test.go | 10 ++++++++++ internal/query/member.go | 10 +++++----- internal/query/org_member.go | 6 ++++++ internal/query/org_member_test.go | 10 ++++++++++ internal/query/project_grant_member.go | 6 ++++++ internal/query/project_grant_member_test.go | 10 ++++++++++ internal/query/project_member.go | 6 ++++++ internal/query/project_member_test.go | 10 ++++++++++ proto/zitadel/member.proto | 8 ++++++++ 11 files changed, 78 insertions(+), 5 deletions(-) diff --git a/internal/api/grpc/member/converter.go b/internal/api/grpc/member/converter.go index 0e5c87ceb1..af81d8ea45 100644 --- a/internal/api/grpc/member/converter.go +++ b/internal/api/grpc/member/converter.go @@ -34,6 +34,7 @@ func MemberToPb(assetAPIPrefix string, m *query.Member) *member_pb.Member { m.ChangeDate, m.ResourceOwner, ), + UserResourceOwner: m.UserResourceOwner, } } diff --git a/internal/query/iam_member.go b/internal/query/iam_member.go index 9f1c5521c9..87b906aa51 100644 --- a/internal/query/iam_member.go +++ b/internal/query/iam_member.go @@ -44,6 +44,10 @@ var ( name: projection.MemberResourceOwner, table: instanceMemberTable, } + InstanceMemberUserResourceOwner = Column{ + name: projection.MemberUserResourceOwner, + table: instanceMemberTable, + } InstanceMemberInstanceID = Column{ name: projection.MemberInstanceID, table: instanceMemberTable, @@ -96,6 +100,7 @@ func prepareInstanceMembersQuery(ctx context.Context, db prepareDatabase) (sq.Se InstanceMemberChangeDate.identifier(), InstanceMemberSequence.identifier(), InstanceMemberResourceOwner.identifier(), + InstanceMemberUserResourceOwner.identifier(), InstanceMemberUserID.identifier(), InstanceMemberRoles.identifier(), LoginNameNameCol.identifier(), @@ -138,6 +143,7 @@ func prepareInstanceMembersQuery(ctx context.Context, db prepareDatabase) (sq.Se &member.ChangeDate, &member.Sequence, &member.ResourceOwner, + &member.UserResourceOwner, &member.UserID, &member.Roles, &preferredLoginName, diff --git a/internal/query/iam_member_test.go b/internal/query/iam_member_test.go index 2ab62d3244..38b9bbc8bc 100644 --- a/internal/query/iam_member_test.go +++ b/internal/query/iam_member_test.go @@ -18,6 +18,7 @@ var ( ", members.change_date" + ", members.sequence" + ", members.resource_owner" + + ", members.user_resource_owner" + ", members.user_id" + ", members.roles" + ", projections.login_names3.login_name" + @@ -45,6 +46,7 @@ var ( "change_date", "sequence", "resource_owner", + "user_resource_owner", "user_id", "roles", "login_name", @@ -97,6 +99,7 @@ func Test_IAMMemberPrepares(t *testing.T) { testNow, uint64(20211206), "ro", + "uro", "user-id", database.TextArray[string]{"role-1", "role-2"}, "gigi@caos-ag.zitadel.ch", @@ -121,6 +124,7 @@ func Test_IAMMemberPrepares(t *testing.T) { ChangeDate: testNow, Sequence: 20211206, ResourceOwner: "ro", + UserResourceOwner: "uro", UserID: "user-id", Roles: database.TextArray[string]{"role-1", "role-2"}, PreferredLoginName: "gigi@caos-ag.zitadel.ch", @@ -147,6 +151,7 @@ func Test_IAMMemberPrepares(t *testing.T) { testNow, uint64(20211206), "ro", + "uro", "user-id", database.TextArray[string]{"role-1", "role-2"}, "machine@caos-ag.zitadel.ch", @@ -171,6 +176,7 @@ func Test_IAMMemberPrepares(t *testing.T) { ChangeDate: testNow, Sequence: 20211206, ResourceOwner: "ro", + UserResourceOwner: "uro", UserID: "user-id", Roles: database.TextArray[string]{"role-1", "role-2"}, PreferredLoginName: "machine@caos-ag.zitadel.ch", @@ -197,6 +203,7 @@ func Test_IAMMemberPrepares(t *testing.T) { testNow, uint64(20211206), "ro", + "uro", "user-id-1", database.TextArray[string]{"role-1", "role-2"}, "gigi@caos-ag.zitadel.ch", @@ -213,6 +220,7 @@ func Test_IAMMemberPrepares(t *testing.T) { testNow, uint64(20211206), "ro", + "uro", "user-id-2", database.TextArray[string]{"role-1", "role-2"}, "machine@caos-ag.zitadel.ch", @@ -237,6 +245,7 @@ func Test_IAMMemberPrepares(t *testing.T) { ChangeDate: testNow, Sequence: 20211206, ResourceOwner: "ro", + UserResourceOwner: "uro", UserID: "user-id-1", Roles: database.TextArray[string]{"role-1", "role-2"}, PreferredLoginName: "gigi@caos-ag.zitadel.ch", @@ -252,6 +261,7 @@ func Test_IAMMemberPrepares(t *testing.T) { ChangeDate: testNow, Sequence: 20211206, ResourceOwner: "ro", + UserResourceOwner: "uro", UserID: "user-id-2", Roles: database.TextArray[string]{"role-1", "role-2"}, PreferredLoginName: "machine@caos-ag.zitadel.ch", diff --git a/internal/query/member.go b/internal/query/member.go index 2c4b4db5fe..584ae15d1c 100644 --- a/internal/query/member.go +++ b/internal/query/member.go @@ -47,11 +47,11 @@ type Members struct { } type Member struct { - CreationDate time.Time - ChangeDate time.Time - Sequence uint64 - ResourceOwner string - + CreationDate time.Time + ChangeDate time.Time + Sequence uint64 + ResourceOwner string + UserResourceOwner string UserID string Roles database.TextArray[string] PreferredLoginName string diff --git a/internal/query/org_member.go b/internal/query/org_member.go index ea452fe357..4daa31d341 100644 --- a/internal/query/org_member.go +++ b/internal/query/org_member.go @@ -44,6 +44,10 @@ var ( name: projection.MemberResourceOwner, table: orgMemberTable, } + OrgMemberUserResourceOwner = Column{ + name: projection.MemberUserResourceOwner, + table: orgMemberTable, + } OrgMemberInstanceID = Column{ name: projection.MemberInstanceID, table: orgMemberTable, @@ -99,6 +103,7 @@ func prepareOrgMembersQuery(ctx context.Context, db prepareDatabase) (sq.SelectB OrgMemberChangeDate.identifier(), OrgMemberSequence.identifier(), OrgMemberResourceOwner.identifier(), + OrgMemberUserResourceOwner.identifier(), OrgMemberUserID.identifier(), OrgMemberRoles.identifier(), LoginNameNameCol.identifier(), @@ -141,6 +146,7 @@ func prepareOrgMembersQuery(ctx context.Context, db prepareDatabase) (sq.SelectB &member.ChangeDate, &member.Sequence, &member.ResourceOwner, + &member.UserResourceOwner, &member.UserID, &member.Roles, &preferredLoginName, diff --git a/internal/query/org_member_test.go b/internal/query/org_member_test.go index d0247c39d3..d42c9b4317 100644 --- a/internal/query/org_member_test.go +++ b/internal/query/org_member_test.go @@ -18,6 +18,7 @@ var ( ", members.change_date" + ", members.sequence" + ", members.resource_owner" + + ", members.user_resource_owner" + ", members.user_id" + ", members.roles" + ", projections.login_names3.login_name" + @@ -49,6 +50,7 @@ var ( "change_date", "sequence", "resource_owner", + "user_resource_owner", "user_id", "roles", "login_name", @@ -101,6 +103,7 @@ func Test_OrgMemberPrepares(t *testing.T) { testNow, uint64(20211206), "ro", + "uro", "user-id", database.TextArray[string]{"role-1", "role-2"}, "gigi@caos-ag.zitadel.ch", @@ -125,6 +128,7 @@ func Test_OrgMemberPrepares(t *testing.T) { ChangeDate: testNow, Sequence: 20211206, ResourceOwner: "ro", + UserResourceOwner: "uro", UserID: "user-id", Roles: database.TextArray[string]{"role-1", "role-2"}, PreferredLoginName: "gigi@caos-ag.zitadel.ch", @@ -151,6 +155,7 @@ func Test_OrgMemberPrepares(t *testing.T) { testNow, uint64(20211206), "ro", + "uro", "user-id", database.TextArray[string]{"role-1", "role-2"}, "machine@caos-ag.zitadel.ch", @@ -175,6 +180,7 @@ func Test_OrgMemberPrepares(t *testing.T) { ChangeDate: testNow, Sequence: 20211206, ResourceOwner: "ro", + UserResourceOwner: "uro", UserID: "user-id", Roles: database.TextArray[string]{"role-1", "role-2"}, PreferredLoginName: "machine@caos-ag.zitadel.ch", @@ -201,6 +207,7 @@ func Test_OrgMemberPrepares(t *testing.T) { testNow, uint64(20211206), "ro", + "uro", "user-id-1", database.TextArray[string]{"role-1", "role-2"}, "gigi@caos-ag.zitadel.ch", @@ -217,6 +224,7 @@ func Test_OrgMemberPrepares(t *testing.T) { testNow, uint64(20211206), "ro", + "uro", "user-id-2", database.TextArray[string]{"role-1", "role-2"}, "machine@caos-ag.zitadel.ch", @@ -241,6 +249,7 @@ func Test_OrgMemberPrepares(t *testing.T) { ChangeDate: testNow, Sequence: 20211206, ResourceOwner: "ro", + UserResourceOwner: "uro", UserID: "user-id-1", Roles: database.TextArray[string]{"role-1", "role-2"}, PreferredLoginName: "gigi@caos-ag.zitadel.ch", @@ -256,6 +265,7 @@ func Test_OrgMemberPrepares(t *testing.T) { ChangeDate: testNow, Sequence: 20211206, ResourceOwner: "ro", + UserResourceOwner: "uro", UserID: "user-id-2", Roles: database.TextArray[string]{"role-1", "role-2"}, PreferredLoginName: "machine@caos-ag.zitadel.ch", diff --git a/internal/query/project_grant_member.go b/internal/query/project_grant_member.go index c13300713f..0820ada826 100644 --- a/internal/query/project_grant_member.go +++ b/internal/query/project_grant_member.go @@ -43,6 +43,10 @@ var ( name: projection.MemberResourceOwner, table: projectGrantMemberTable, } + ProjectGrantMemberUserResourceOwner = Column{ + name: projection.MemberUserResourceOwner, + table: projectGrantMemberTable, + } ProjectGrantMemberInstanceID = Column{ name: projection.MemberInstanceID, table: projectGrantMemberTable, @@ -108,6 +112,7 @@ func prepareProjectGrantMembersQuery(ctx context.Context, db prepareDatabase) (s ProjectGrantMemberChangeDate.identifier(), ProjectGrantMemberSequence.identifier(), ProjectGrantMemberResourceOwner.identifier(), + ProjectGrantMemberUserResourceOwner.identifier(), ProjectGrantMemberUserID.identifier(), ProjectGrantMemberRoles.identifier(), LoginNameNameCol.identifier(), @@ -151,6 +156,7 @@ func prepareProjectGrantMembersQuery(ctx context.Context, db prepareDatabase) (s &member.ChangeDate, &member.Sequence, &member.ResourceOwner, + &member.UserResourceOwner, &member.UserID, &member.Roles, &preferredLoginName, diff --git a/internal/query/project_grant_member_test.go b/internal/query/project_grant_member_test.go index 839a1f2c1b..f55841ff76 100644 --- a/internal/query/project_grant_member_test.go +++ b/internal/query/project_grant_member_test.go @@ -18,6 +18,7 @@ var ( ", members.change_date" + ", members.sequence" + ", members.resource_owner" + + ", members.user_resource_owner" + ", members.user_id" + ", members.roles" + ", projections.login_names3.login_name" + @@ -52,6 +53,7 @@ var ( "change_date", "sequence", "resource_owner", + "user_resource_owner", "user_id", "roles", "login_name", @@ -104,6 +106,7 @@ func Test_ProjectGrantMemberPrepares(t *testing.T) { testNow, uint64(20211206), "ro", + "uro", "user-id", database.TextArray[string]{"role-1", "role-2"}, "gigi@caos-ag.zitadel.ch", @@ -128,6 +131,7 @@ func Test_ProjectGrantMemberPrepares(t *testing.T) { ChangeDate: testNow, Sequence: 20211206, ResourceOwner: "ro", + UserResourceOwner: "uro", UserID: "user-id", Roles: database.TextArray[string]{"role-1", "role-2"}, PreferredLoginName: "gigi@caos-ag.zitadel.ch", @@ -154,6 +158,7 @@ func Test_ProjectGrantMemberPrepares(t *testing.T) { testNow, uint64(20211206), "ro", + "uro", "user-id", database.TextArray[string]{"role-1", "role-2"}, "machine@caos-ag.zitadel.ch", @@ -178,6 +183,7 @@ func Test_ProjectGrantMemberPrepares(t *testing.T) { ChangeDate: testNow, Sequence: 20211206, ResourceOwner: "ro", + UserResourceOwner: "uro", UserID: "user-id", Roles: database.TextArray[string]{"role-1", "role-2"}, PreferredLoginName: "machine@caos-ag.zitadel.ch", @@ -204,6 +210,7 @@ func Test_ProjectGrantMemberPrepares(t *testing.T) { testNow, uint64(20211206), "ro", + "uro", "user-id-1", database.TextArray[string]{"role-1", "role-2"}, "gigi@caos-ag.zitadel.ch", @@ -220,6 +227,7 @@ func Test_ProjectGrantMemberPrepares(t *testing.T) { testNow, uint64(20211206), "ro", + "uro", "user-id-2", database.TextArray[string]{"role-1", "role-2"}, "machine@caos-ag.zitadel.ch", @@ -244,6 +252,7 @@ func Test_ProjectGrantMemberPrepares(t *testing.T) { ChangeDate: testNow, Sequence: 20211206, ResourceOwner: "ro", + UserResourceOwner: "uro", UserID: "user-id-1", Roles: database.TextArray[string]{"role-1", "role-2"}, PreferredLoginName: "gigi@caos-ag.zitadel.ch", @@ -259,6 +268,7 @@ func Test_ProjectGrantMemberPrepares(t *testing.T) { ChangeDate: testNow, Sequence: 20211206, ResourceOwner: "ro", + UserResourceOwner: "uro", UserID: "user-id-2", Roles: database.TextArray[string]{"role-1", "role-2"}, PreferredLoginName: "machine@caos-ag.zitadel.ch", diff --git a/internal/query/project_member.go b/internal/query/project_member.go index a86246bdd7..347eac12b9 100644 --- a/internal/query/project_member.go +++ b/internal/query/project_member.go @@ -44,6 +44,10 @@ var ( name: projection.MemberResourceOwner, table: projectMemberTable, } + ProjectMemberUserResourceOwner = Column{ + name: projection.MemberUserResourceOwner, + table: projectMemberTable, + } ProjectMemberInstanceID = Column{ name: projection.MemberInstanceID, table: projectMemberTable, @@ -99,6 +103,7 @@ func prepareProjectMembersQuery(ctx context.Context, db prepareDatabase) (sq.Sel ProjectMemberChangeDate.identifier(), ProjectMemberSequence.identifier(), ProjectMemberResourceOwner.identifier(), + ProjectMemberUserResourceOwner.identifier(), ProjectMemberUserID.identifier(), ProjectMemberRoles.identifier(), LoginNameNameCol.identifier(), @@ -141,6 +146,7 @@ func prepareProjectMembersQuery(ctx context.Context, db prepareDatabase) (sq.Sel &member.ChangeDate, &member.Sequence, &member.ResourceOwner, + &member.UserResourceOwner, &member.UserID, &member.Roles, &preferredLoginName, diff --git a/internal/query/project_member_test.go b/internal/query/project_member_test.go index 74f35ef6ee..21be454f43 100644 --- a/internal/query/project_member_test.go +++ b/internal/query/project_member_test.go @@ -18,6 +18,7 @@ var ( ", members.change_date" + ", members.sequence" + ", members.resource_owner" + + ", members.user_resource_owner" + ", members.user_id" + ", members.roles" + ", projections.login_names3.login_name" + @@ -49,6 +50,7 @@ var ( "change_date", "sequence", "resource_owner", + "user_resource_owner", "user_id", "roles", "login_name", @@ -101,6 +103,7 @@ func Test_ProjectMemberPrepares(t *testing.T) { testNow, uint64(20211206), "ro", + "uro", "user-id", database.TextArray[string]{"role-1", "role-2"}, "gigi@caos-ag.zitadel.ch", @@ -125,6 +128,7 @@ func Test_ProjectMemberPrepares(t *testing.T) { ChangeDate: testNow, Sequence: 20211206, ResourceOwner: "ro", + UserResourceOwner: "uro", UserID: "user-id", Roles: database.TextArray[string]{"role-1", "role-2"}, PreferredLoginName: "gigi@caos-ag.zitadel.ch", @@ -151,6 +155,7 @@ func Test_ProjectMemberPrepares(t *testing.T) { testNow, uint64(20211206), "ro", + "uro", "user-id", database.TextArray[string]{"role-1", "role-2"}, "machine@caos-ag.zitadel.ch", @@ -175,6 +180,7 @@ func Test_ProjectMemberPrepares(t *testing.T) { ChangeDate: testNow, Sequence: 20211206, ResourceOwner: "ro", + UserResourceOwner: "uro", UserID: "user-id", Roles: database.TextArray[string]{"role-1", "role-2"}, PreferredLoginName: "machine@caos-ag.zitadel.ch", @@ -201,6 +207,7 @@ func Test_ProjectMemberPrepares(t *testing.T) { testNow, uint64(20211206), "ro", + "uro", "user-id-1", database.TextArray[string]{"role-1", "role-2"}, "gigi@caos-ag.zitadel.ch", @@ -217,6 +224,7 @@ func Test_ProjectMemberPrepares(t *testing.T) { testNow, uint64(20211206), "ro", + "uro", "user-id-2", database.TextArray[string]{"role-1", "role-2"}, "machine@caos-ag.zitadel.ch", @@ -241,6 +249,7 @@ func Test_ProjectMemberPrepares(t *testing.T) { ChangeDate: testNow, Sequence: 20211206, ResourceOwner: "ro", + UserResourceOwner: "uro", UserID: "user-id-1", Roles: database.TextArray[string]{"role-1", "role-2"}, PreferredLoginName: "gigi@caos-ag.zitadel.ch", @@ -256,6 +265,7 @@ func Test_ProjectMemberPrepares(t *testing.T) { ChangeDate: testNow, Sequence: 20211206, ResourceOwner: "ro", + UserResourceOwner: "uro", UserID: "user-id-2", Roles: database.TextArray[string]{"role-1", "role-2"}, PreferredLoginName: "machine@caos-ag.zitadel.ch", diff --git a/proto/zitadel/member.proto b/proto/zitadel/member.proto index 07091e195e..c3351a99d3 100644 --- a/proto/zitadel/member.proto +++ b/proto/zitadel/member.proto @@ -63,6 +63,14 @@ message Member { description: "type of the user (human / machine)" } ]; + + // The organization the user belong to. + string user_resource_owner = 11 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629023906488334\""; + } + ]; + } message SearchQuery { From 1949d1546af4ea3231204fead58546ee9e831027 Mon Sep 17 00:00:00 2001 From: Silvan <27845747+adlerhurst@users.noreply.github.com> Date: Wed, 15 Jan 2025 11:22:16 +0100 Subject: [PATCH 20/30] fix: set correct owner on project grants (#9089) # Which Problems Are Solved In versions previous to v2.66 it was possible to set a different resource owner on project grants. This was introduced with the new resource based API. The resource owner was possible to overwrite using the x-zitadel-org header. Because of this issue project grants got the wrong resource owner, instead of the owner of the project it got the granted org which is wrong because a resource owner of an aggregate is not allowed to change. # How the Problems Are Solved - The wrong owners of the events are set to the original owner of the project. - A new event is pushed to these aggregates `project.owner.corrected` - The projection updates the owners of the user grants if that event was written # Additional Changes The eventstore push function (replaced in version 2.66) writes the correct resource owner. # Additional Context closes https://github.com/zitadel/zitadel/issues/9072 --- cmd/setup/45.go | 111 +++++++++++++++++++ cmd/setup/45.sql | 79 +++++++++++++ cmd/setup/config.go | 1 + cmd/setup/setup.go | 2 + docs/docs/support/advisory/a10014.md | 26 +++++ docs/docs/support/technical_advisory.mdx | 12 ++ internal/eventstore/handler/v2/statement.go | 6 + internal/eventstore/v3/sequence.go | 13 ++- internal/query/projection/project_grant.go | 18 +++ internal/repository/owner/owner_corrected.go | 40 +++++++ internal/repository/project/project.go | 1 + 11 files changed, 308 insertions(+), 1 deletion(-) create mode 100644 cmd/setup/45.go create mode 100644 cmd/setup/45.sql create mode 100644 docs/docs/support/advisory/a10014.md create mode 100644 internal/repository/owner/owner_corrected.go diff --git a/cmd/setup/45.go b/cmd/setup/45.go new file mode 100644 index 0000000000..d8318a6d59 --- /dev/null +++ b/cmd/setup/45.go @@ -0,0 +1,111 @@ +package setup + +import ( + "context" + _ "embed" + "encoding/json" + "fmt" + + "github.com/zitadel/logging" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/query/projection" + "github.com/zitadel/zitadel/internal/repository/instance" + "github.com/zitadel/zitadel/internal/repository/owner" + "github.com/zitadel/zitadel/internal/repository/project" +) + +var ( + //go:embed 45.sql + correctProjectOwnerEvents string +) + +type CorrectProjectOwners struct { + eventstore *eventstore.Eventstore +} + +func (mig *CorrectProjectOwners) Execute(ctx context.Context, _ eventstore.Event) error { + instances, err := mig.eventstore.InstanceIDs( + ctx, + eventstore.NewSearchQueryBuilder(eventstore.ColumnsInstanceIDs). + OrderDesc(). + AddQuery(). + AggregateTypes("instance"). + EventTypes(instance.InstanceAddedEventType). + Builder(), + ) + if err != nil { + return err + } + + ctx = authz.SetCtxData(ctx, authz.CtxData{UserID: "SETUP"}) + for i, instance := range instances { + ctx = authz.WithInstanceID(ctx, instance) + logging.WithFields("instance_id", instance, "migration", mig.String(), "progress", fmt.Sprintf("%d/%d", i+1, len(instances))).Info("correct owners of projects") + didCorrect, err := mig.correctInstanceProjects(ctx, instance) + if err != nil { + return err + } + if !didCorrect { + continue + } + _, err = projection.ProjectGrantProjection.Trigger(ctx) + logging.OnError(err).Debug("failed triggering project grant projection to update owners") + } + return nil +} + +func (mig *CorrectProjectOwners) correctInstanceProjects(ctx context.Context, instance string) (didCorrect bool, err error) { + var correctedOwners []eventstore.Command + + tx, err := mig.eventstore.Client().BeginTx(ctx, nil) + if err != nil { + return false, err + } + defer func() { + if err != nil { + _ = tx.Rollback() + return + } + err = tx.Commit() + }() + + rows, err := tx.QueryContext(ctx, correctProjectOwnerEvents, instance) + if err != nil { + return false, err + } + defer rows.Close() + + for rows.Next() { + aggregate := &eventstore.Aggregate{ + InstanceID: instance, + Type: project.AggregateType, + Version: project.AggregateVersion, + } + var payload json.RawMessage + err := rows.Scan( + &aggregate.ID, + &aggregate.ResourceOwner, + &payload, + ) + if err != nil { + return false, err + } + previousOwners := make(map[uint32]string) + if err := json.Unmarshal(payload, &previousOwners); err != nil { + return false, err + } + correctedOwners = append(correctedOwners, owner.NewCorrected(ctx, aggregate, previousOwners)) + } + if rows.Err() != nil { + return false, rows.Err() + } + + _, err = mig.eventstore.PushWithClient(ctx, tx, correctedOwners...) + return len(correctedOwners) > 0, err +} + +func (*CorrectProjectOwners) String() string { + return "43_correct_project_owners" +} diff --git a/cmd/setup/45.sql b/cmd/setup/45.sql new file mode 100644 index 0000000000..0e90a2683d --- /dev/null +++ b/cmd/setup/45.sql @@ -0,0 +1,79 @@ +WITH corrupt_streams AS ( + select + e.instance_id + , e.aggregate_type + , e.aggregate_id + , min(e.sequence) as min_sequence + , count(distinct e.owner) as owner_count + from + eventstore.events2 e + where + e.instance_id = $1 + and aggregate_type = 'project' + group by + e.instance_id + , e.aggregate_type + , e.aggregate_id + having + count(distinct e.owner) > 1 +), correct_owners AS ( + select + e.instance_id + , e.aggregate_type + , e.aggregate_id + , e.owner + from + eventstore.events2 e + join + corrupt_streams cs + on + e.instance_id = cs.instance_id + and e.aggregate_type = cs.aggregate_type + and e.aggregate_id = cs.aggregate_id + and e.sequence = cs.min_sequence +), wrong_events AS ( + select + e.instance_id + , e.aggregate_type + , e.aggregate_id + , e.sequence + , e.owner wrong_owner + , co.owner correct_owner + from + eventstore.events2 e + join + correct_owners co + on + e.instance_id = co.instance_id + and e.aggregate_type = co.aggregate_type + and e.aggregate_id = co.aggregate_id + and e.owner <> co.owner +), updated_events AS ( + UPDATE eventstore.events2 e + SET owner = we.correct_owner + FROM + wrong_events we + WHERE + e.instance_id = we.instance_id + and e.aggregate_type = we.aggregate_type + and e.aggregate_id = we.aggregate_id + and e.sequence = we.sequence + RETURNING + we.aggregate_id + , we.correct_owner + , we.sequence + , we.wrong_owner +) +SELECT + ue.aggregate_id + , ue.correct_owner + , jsonb_object_agg( + ue.sequence::TEXT --formant to string because crdb is not able to handle int + , ue.wrong_owner + ) payload +FROM + updated_events ue +GROUP BY + ue.aggregate_id + , ue.correct_owner +; diff --git a/cmd/setup/config.go b/cmd/setup/config.go index 9f34c2baa5..0a5493b771 100644 --- a/cmd/setup/config.go +++ b/cmd/setup/config.go @@ -130,6 +130,7 @@ type Steps struct { s42Apps7OIDCConfigsLoginVersion *Apps7OIDCConfigsLoginVersion s43CreateFieldsDomainIndex *CreateFieldsDomainIndex s44ReplaceCurrentSequencesIndex *ReplaceCurrentSequencesIndex + s45CorrectProjectOwners *CorrectProjectOwners } func MustNewSteps(v *viper.Viper) *Steps { diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index 4ffef441af..d55ea0f3fe 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -173,6 +173,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s42Apps7OIDCConfigsLoginVersion = &Apps7OIDCConfigsLoginVersion{dbClient: esPusherDBClient} steps.s43CreateFieldsDomainIndex = &CreateFieldsDomainIndex{dbClient: queryDBClient} steps.s44ReplaceCurrentSequencesIndex = &ReplaceCurrentSequencesIndex{dbClient: esPusherDBClient} + steps.s45CorrectProjectOwners = &CorrectProjectOwners{eventstore: eventstoreClient} err = projection.Create(ctx, projectionDBClient, eventstoreClient, config.Projections, nil, nil, nil) logging.OnError(err).Fatal("unable to start projections") @@ -227,6 +228,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s36FillV2Milestones, steps.s38BackChannelLogoutNotificationStart, steps.s44ReplaceCurrentSequencesIndex, + steps.s45CorrectProjectOwners, } { mustExecuteMigration(ctx, eventstoreClient, step, "migration failed") } diff --git a/docs/docs/support/advisory/a10014.md b/docs/docs/support/advisory/a10014.md new file mode 100644 index 0000000000..be19dd2cbf --- /dev/null +++ b/docs/docs/support/advisory/a10014.md @@ -0,0 +1,26 @@ +--- +title: Technical Advisory 10014 +--- + +## Date + +Versions: >= v2.67.3, v2.66 >= v2.66.6 + +Date: 2025-01-17 + +## Description + +Prior to version [v2.66.0](https://github.com/zitadel/zitadel/releases/tag/v2.66.0), some project grants were incorrectly created under the granted organization instead of the project owner's organization. To find these grants, users had to set the `x-zitadel-orgid` header to the granted organization ID when using the [`ListAllProjectGrants`](/apis/resources/mgmt/management-service-add-project-grant) gRPC method. + +Zitadel [v2.66.0](https://github.com/zitadel/zitadel/releases/tag/v2.66.0) corrected this behavior for new grants. However, existing grants were not automatically updated. Version v2.66.6 corrects the owner of these existing grants. + +## Impact + +After the release of v2.66.6, if your application uses the [`ListAllProjectGrants`](/apis/resources/mgmt/management-service-add-project-grant) method with the `x-zitadel-orgid` header set to the granted organization ID, you will not retrieve any results. + +## Mitigation + +To ensure your application continues to function correctly after the release of v2.66.6, implement the following changes: + +1. **Conditional Header:** Only set the `x-zitadel-orgid` header to the project owner's organization ID if the user executing the [`ListAllProjectGrants`](/apis/resources/mgmt/management-service-add-project-grant) method belongs to a different organization than the project. +2. **Use `grantedOrgIdQuery`:** Utilize the `grantedOrgIdQuery` parameter to filter grants for the specific granted organization. \ No newline at end of file diff --git a/docs/docs/support/technical_advisory.mdx b/docs/docs/support/technical_advisory.mdx index 7562ff3870..8805e2e1d8 100644 --- a/docs/docs/support/technical_advisory.mdx +++ b/docs/docs/support/technical_advisory.mdx @@ -214,6 +214,18 @@ We understand that these advisories may include breaking changes, and we aim to - 2024-12-09 + + + A-10014 + + Correction of project grant owner + Breaking Behavior Change + + Correct project grant owners, ensuring they are correctly associated with the projects organization. + + - + 2025-01-10 + ## Subscribe to our Mailing List diff --git a/internal/eventstore/handler/v2/statement.go b/internal/eventstore/handler/v2/statement.go index 961881d24b..a02e5d3580 100644 --- a/internal/eventstore/handler/v2/statement.go +++ b/internal/eventstore/handler/v2/statement.go @@ -601,6 +601,12 @@ func NewCond(name string, value interface{}) Condition { } } +func NewUnequalCond(name string, value any) Condition { + return func(param string) (string, []any) { + return name + " <> " + param, []any{value} + } +} + func NewNamespacedCondition(name string, value interface{}) NamespacedCondition { return func(namespace string) Condition { return NewCond(namespace+"."+name, value) diff --git a/internal/eventstore/v3/sequence.go b/internal/eventstore/v3/sequence.go index 7d97e1080d..1976af4093 100644 --- a/internal/eventstore/v3/sequence.go +++ b/internal/eventstore/v3/sequence.go @@ -125,7 +125,18 @@ func scanToSequence(rows *sql.Rows, sequences []*latestSequence) error { return nil } sequence.sequence = currentSequence - if sequence.aggregate.ResourceOwner == "" { + if resourceOwner != "" && sequence.aggregate.ResourceOwner != "" && sequence.aggregate.ResourceOwner != resourceOwner { + logging.WithFields( + "current_sequence", sequence.sequence, + "instance_id", sequence.aggregate.InstanceID, + "agg_type", sequence.aggregate.Type, + "agg_id", sequence.aggregate.ID, + "current_owner", resourceOwner, + "provided_owner", sequence.aggregate.ResourceOwner, + ).Info("would have set wrong resource owner") + } + // set resource owner from previous events + if resourceOwner != "" { sequence.aggregate.ResourceOwner = resourceOwner } diff --git a/internal/query/projection/project_grant.go b/internal/query/projection/project_grant.go index d6fbde8556..d5a075c486 100644 --- a/internal/query/projection/project_grant.go +++ b/internal/query/projection/project_grant.go @@ -93,6 +93,10 @@ func (p *projectGrantProjection) Reducers() []handler.AggregateReducer { Event: project.ProjectRemovedType, Reduce: p.reduceProjectRemoved, }, + { + Event: project.ProjectOwnerCorrected, + Reduce: p.reduceOwnerCorrected, + }, }, }, { @@ -269,3 +273,17 @@ func (p *projectGrantProjection) reduceOwnerRemoved(event eventstore.Event) (*ha ), ), nil } + +func (p *projectGrantProjection) reduceOwnerCorrected(event eventstore.Event) (*handler.Statement, error) { + return handler.NewUpdateStatement( + event, + []handler.Column{ + handler.NewCol(ProjectGrantColumnResourceOwner, event.Aggregate().ResourceOwner), + }, + []handler.Condition{ + handler.NewCond(ProjectGrantColumnInstanceID, event.Aggregate().InstanceID), + handler.NewCond(ProjectGrantColumnProjectID, event.Aggregate().ID), + handler.NewUnequalCond(ProjectGrantColumnResourceOwner, event.Aggregate().ResourceOwner), + }, + ), nil +} diff --git a/internal/repository/owner/owner_corrected.go b/internal/repository/owner/owner_corrected.go new file mode 100644 index 0000000000..29bb4842d4 --- /dev/null +++ b/internal/repository/owner/owner_corrected.go @@ -0,0 +1,40 @@ +package owner + +import ( + "context" + + "github.com/zitadel/zitadel/internal/eventstore" +) + +const OwnerCorrectedType = ".owner.corrected" + +type Corrected struct { + eventstore.BaseEvent `json:"-"` + + PreviousOwners map[uint32]string `json:"previousOwners,omitempty"` +} + +var _ eventstore.Command = (*Corrected)(nil) + +func (e *Corrected) Payload() interface{} { + return e +} + +func (e *Corrected) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func NewCorrected( + ctx context.Context, + aggregate *eventstore.Aggregate, + previousOwners map[uint32]string, +) *Corrected { + return &Corrected{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + eventstore.EventType(aggregate.Type+OwnerCorrectedType), + ), + PreviousOwners: previousOwners, + } +} diff --git a/internal/repository/project/project.go b/internal/repository/project/project.go index 6147a632eb..44f882b3e1 100644 --- a/internal/repository/project/project.go +++ b/internal/repository/project/project.go @@ -16,6 +16,7 @@ const ( ProjectDeactivatedType = projectEventTypePrefix + "deactivated" ProjectReactivatedType = projectEventTypePrefix + "reactivated" ProjectRemovedType = projectEventTypePrefix + "removed" + ProjectOwnerCorrected = projectEventTypePrefix + "owner.corrected" ProjectSearchType = "project" ProjectObjectRevision = uint8(1) From 40082745f473945de31bfada638c56372f93e48d Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Wed, 15 Jan 2025 11:39:28 +0100 Subject: [PATCH 21/30] fix(login): allow fallback to local auth in case of IdP errors (#9178) # Which Problems Are Solved The current login will always prefer external authentication (through an IdP) over local authentication. So as soon as either the user had connected to an IdP or even when the login policy was just set up to have an IdP allowed, users would be redirected to that IdP for (re)authentication. This could lead to problems, where the IdP was not available or any other error occurred in the process (such as secret expired for EntraID). Even when local authentication (passkeys or password) was allowed for the corresponding user, they would always be redirected to the IdP again, preventing any authentication. If admins were affected, they might not even be able to update the client secret of the IdP. # How the Problems Are Solved Errors during the external IdP flow are handled in an `externalAuthFailed` function, which will check if the organisation allows local authentication and if the user has set up such. If either password or passkeys is set up, the corresponding login page will be presented to the user. As already with local auth passkeys is preferred over password authentication. The user is informed that the external login failed and fail back to local auth as an error on the corresponding page in a focused mode. Any interaction or after 5 second the focus mode is disabled. # Additional Changes None. # Additional Context closes #6466 --- .../api/ui/login/change_password_handler.go | 8 +- internal/api/ui/login/device_auth.go | 8 +- .../api/ui/login/external_provider_handler.go | 119 +++++++++++++----- .../api/ui/login/init_password_handler.go | 8 +- internal/api/ui/login/init_user_handler.go | 8 +- internal/api/ui/login/invite_user_handler.go | 6 +- internal/api/ui/login/ldap_handler.go | 6 +- internal/api/ui/login/link_users_handler.go | 6 +- internal/api/ui/login/login_handler.go | 6 +- .../api/ui/login/login_success_handler.go | 6 +- internal/api/ui/login/logout_handler.go | 2 +- internal/api/ui/login/mail_verify_handler.go | 8 +- .../api/ui/login/mfa_init_done_handler.go | 3 +- internal/api/ui/login/mfa_init_sms.go | 6 +- internal/api/ui/login/mfa_init_u2f.go | 7 +- .../api/ui/login/mfa_init_verify_handler.go | 6 +- internal/api/ui/login/mfa_prompt_handler.go | 6 +- internal/api/ui/login/mfa_verify_handler.go | 6 +- .../api/ui/login/mfa_verify_otp_handler.go | 6 +- .../api/ui/login/mfa_verify_u2f_handler.go | 7 +- internal/api/ui/login/password_handler.go | 6 +- .../api/ui/login/password_reset_handler.go | 6 +- .../ui/login/passwordless_login_handler.go | 17 +-- .../ui/login/passwordless_prompt_handler.go | 6 +- .../passwordless_registration_handler.go | 11 +- internal/api/ui/login/register_handler.go | 6 +- .../api/ui/login/register_option_handler.go | 6 +- internal/api/ui/login/register_org_handler.go | 6 +- internal/api/ui/login/renderer.go | 33 +++-- internal/api/ui/login/select_user_handler.go | 2 +- internal/api/ui/login/static/i18n/bg.yaml | 4 + internal/api/ui/login/static/i18n/cs.yaml | 4 + internal/api/ui/login/static/i18n/de.yaml | 4 + internal/api/ui/login/static/i18n/en.yaml | 4 + internal/api/ui/login/static/i18n/es.yaml | 4 + internal/api/ui/login/static/i18n/fr.yaml | 4 + internal/api/ui/login/static/i18n/hu.yaml | 4 + internal/api/ui/login/static/i18n/id.yaml | 4 + internal/api/ui/login/static/i18n/it.yaml | 4 + internal/api/ui/login/static/i18n/ja.yaml | 4 + internal/api/ui/login/static/i18n/ko.yaml | 4 + internal/api/ui/login/static/i18n/mk.yaml | 4 + internal/api/ui/login/static/i18n/nl.yaml | 4 + internal/api/ui/login/static/i18n/pl.yaml | 4 + internal/api/ui/login/static/i18n/pt.yaml | 4 + internal/api/ui/login/static/i18n/ru.yaml | 4 + internal/api/ui/login/static/i18n/sv.yaml | 4 + internal/api/ui/login/static/i18n/zh.yaml | 4 + .../static/resources/scripts/error_popup.js | 30 +++++ .../static/resources/themes/scss/main.scss | 23 ++++ .../themes/scss/styles/error/error.scss | 4 + .../login/static/templates/error-message.html | 9 +- .../ui/login/static/templates/password.html | 1 + .../login/static/templates/passwordless.html | 1 + .../api/ui/login/username_change_handler.go | 9 +- internal/auth/repository/auth_request.go | 1 + .../eventsourcing/eventstore/auth_request.go | 15 ++- .../eventstore/auth_request_test.go | 80 ++++++++++++ internal/domain/auth_request.go | 1 + 59 files changed, 388 insertions(+), 195 deletions(-) create mode 100644 internal/api/ui/login/static/resources/scripts/error_popup.js diff --git a/internal/api/ui/login/change_password_handler.go b/internal/api/ui/login/change_password_handler.go index 19f2404c94..7f2b83b80f 100644 --- a/internal/api/ui/login/change_password_handler.go +++ b/internal/api/ui/login/change_password_handler.go @@ -35,10 +35,6 @@ func (l *Login) handleChangePassword(w http.ResponseWriter, r *http.Request) { } func (l *Login) renderChangePassword(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, err error) { - var errType, errMessage string - if err != nil { - errType, errMessage = l.getErrorMessage(r, err) - } translator := l.getTranslator(r.Context(), authReq) if authReq == nil || len(authReq.PossibleSteps) < 1 { l.renderError(w, r, authReq, err) @@ -50,7 +46,7 @@ func (l *Login) renderChangePassword(w http.ResponseWriter, r *http.Request, aut return } data := passwordData{ - baseData: l.getBaseData(r, authReq, translator, "PasswordChange.Title", "PasswordChange.Description", errType, errMessage), + baseData: l.getBaseData(r, authReq, translator, "PasswordChange.Title", "PasswordChange.Description", err), profileData: l.getProfileData(authReq), Expired: step.Expired, } @@ -75,6 +71,6 @@ func (l *Login) renderChangePassword(w http.ResponseWriter, r *http.Request, aut func (l *Login) renderChangePasswordDone(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest) { translator := l.getTranslator(r.Context(), authReq) - data := l.getUserData(r, authReq, translator, "PasswordChange.Title", "PasswordChange.Description", "", "") + data := l.getUserData(r, authReq, translator, "PasswordChange.Title", "PasswordChange.Description", nil) l.renderer.RenderTemplate(w, r, translator, l.renderer.Templates[tmplChangePasswordDone], data, nil) } diff --git a/internal/api/ui/login/device_auth.go b/internal/api/ui/login/device_auth.go index e7ddaccd08..fb61102d76 100644 --- a/internal/api/ui/login/device_auth.go +++ b/internal/api/ui/login/device_auth.go @@ -22,13 +22,11 @@ const ( ) func (l *Login) renderDeviceAuthUserCode(w http.ResponseWriter, r *http.Request, err error) { - var errID, errMessage string if err != nil { logging.WithError(err).Error() - errID, errMessage = l.getErrorMessage(r, err) } translator := l.getTranslator(r.Context(), nil) - data := l.getBaseData(r, nil, translator, "DeviceAuth.Title", "DeviceAuth.UserCode.Description", errID, errMessage) + data := l.getBaseData(r, nil, translator, "DeviceAuth.Title", "DeviceAuth.UserCode.Description", err) l.renderer.RenderTemplate(w, r, translator, l.renderer.Templates[tmplDeviceAuthUserCode], data, nil) } @@ -41,7 +39,7 @@ func (l *Login) renderDeviceAuthAction(w http.ResponseWriter, r *http.Request, a ClientID string Scopes []string }{ - baseData: l.getBaseData(r, authReq, translator, "DeviceAuth.Title", "DeviceAuth.Action.Description", "", ""), + baseData: l.getBaseData(r, authReq, translator, "DeviceAuth.Title", "DeviceAuth.Action.Description", nil), AuthRequestID: authReq.ID, Username: authReq.UserName, ClientID: authReq.ApplicationID, @@ -63,7 +61,7 @@ func (l *Login) renderDeviceAuthDone(w http.ResponseWriter, r *http.Request, aut baseData Message string }{ - baseData: l.getBaseData(r, authReq, translator, "DeviceAuth.Title", "DeviceAuth.Done.Description", "", ""), + baseData: l.getBaseData(r, authReq, translator, "DeviceAuth.Title", "DeviceAuth.Done.Description", nil), } switch action { case deviceAuthAllowed: diff --git a/internal/api/ui/login/external_provider_handler.go b/internal/api/ui/login/external_provider_handler.go index c60e0eb0bb..5481c6aed1 100644 --- a/internal/api/ui/login/external_provider_handler.go +++ b/internal/api/ui/login/external_provider_handler.go @@ -2,8 +2,10 @@ package login import ( "context" + "errors" "net/http" "net/url" + "slices" "strings" "github.com/crewjam/saml/samlsp" @@ -150,7 +152,7 @@ func (l *Login) handleIDP(w http.ResponseWriter, r *http.Request, authReq *domai userAgentID, _ := http_mw.UserAgentIDFromCtx(r.Context()) err = l.authRepo.SelectExternalIDP(r.Context(), authReq.ID, identityProvider.ID, userAgentID) if err != nil { - l.renderLogin(w, r, authReq, err) + l.externalAuthFailed(w, r, authReq, err) return } var provider idp.Provider @@ -183,17 +185,17 @@ func (l *Login) handleIDP(w http.ResponseWriter, r *http.Request, authReq *domai case domain.IDPTypeUnspecified: fallthrough default: - l.renderLogin(w, r, authReq, zerrors.ThrowInvalidArgument(nil, "LOGIN-AShek", "Errors.ExternalIDP.IDPTypeNotImplemented")) + l.externalAuthFailed(w, r, authReq, zerrors.ThrowInvalidArgument(nil, "LOGIN-AShek", "Errors.ExternalIDP.IDPTypeNotImplemented")) return } if err != nil { - l.renderLogin(w, r, authReq, err) + l.externalAuthFailed(w, r, authReq, err) return } params := l.sessionParamsFromAuthRequest(r.Context(), authReq, identityProvider.ID) session, err := provider.BeginAuth(r.Context(), authReq.ID, params...) if err != nil { - l.renderLogin(w, r, authReq, err) + l.externalAuthFailed(w, r, authReq, err) return } @@ -215,7 +217,7 @@ func (l *Login) handleIDP(w http.ResponseWriter, r *http.Request, authReq *domai func (l *Login) handleExternalLoginCallbackForm(w http.ResponseWriter, r *http.Request) { err := r.ParseForm() if err != nil { - l.renderLogin(w, r, nil, err) + l.externalAuthFailed(w, r, nil, err) return } state := r.Form.Get(queryState) @@ -223,7 +225,7 @@ func (l *Login) handleExternalLoginCallbackForm(w http.ResponseWriter, r *http.R state = r.Form.Get(queryRelayState) } if state == "" { - l.renderLogin(w, r, nil, zerrors.ThrowInvalidArgument(nil, "LOGIN-dsg3f", "Errors.AuthRequest.NotFound")) + l.externalAuthFailed(w, r, nil, zerrors.ThrowInvalidArgument(nil, "LOGIN-dsg3f", "Errors.AuthRequest.NotFound")) return } l.caches.idpFormCallbacks.Set(r.Context(), &idpFormCallback{ @@ -243,7 +245,7 @@ func (l *Login) handleExternalLoginCallback(w http.ResponseWriter, r *http.Reque // workaround because of CSRF on external identity provider flows using form_post if r.URL.Query().Get(queryMethod) == http.MethodPost { if err := l.setDataFromFormCallback(r, r.URL.Query().Get(queryState)); err != nil { - l.renderLogin(w, r, nil, err) + l.externalAuthFailed(w, r, nil, err) return } } @@ -251,7 +253,7 @@ func (l *Login) handleExternalLoginCallback(w http.ResponseWriter, r *http.Reque data := new(externalIDPCallbackData) err := l.getParseData(r, data) if err != nil { - l.renderLogin(w, r, nil, err) + l.externalAuthFailed(w, r, nil, err) return } if data.State == "" { @@ -261,12 +263,12 @@ func (l *Login) handleExternalLoginCallback(w http.ResponseWriter, r *http.Reque userAgentID, _ := http_mw.UserAgentIDFromCtx(r.Context()) authReq, err := l.authRepo.AuthRequestByID(r.Context(), data.State, userAgentID) if err != nil { - l.externalAuthFailed(w, r, authReq, nil, nil, err) + l.externalAuthCallbackFailed(w, r, authReq, nil, nil, err) return } identityProvider, err := l.getIDPByID(r, authReq.SelectedIDPConfigID) if err != nil { - l.externalAuthFailed(w, r, authReq, nil, nil, err) + l.externalAuthCallbackFailed(w, r, authReq, nil, nil, err) return } var provider idp.Provider @@ -275,75 +277,75 @@ func (l *Login) handleExternalLoginCallback(w http.ResponseWriter, r *http.Reque case domain.IDPTypeOAuth: provider, err = l.oauthProvider(r.Context(), identityProvider) if err != nil { - l.externalAuthFailed(w, r, authReq, nil, nil, err) + l.externalAuthCallbackFailed(w, r, authReq, nil, nil, err) return } session = &oauth.Session{Provider: provider.(*oauth.Provider), Code: data.Code} case domain.IDPTypeOIDC: provider, err = l.oidcProvider(r.Context(), identityProvider) if err != nil { - l.externalAuthFailed(w, r, authReq, nil, nil, err) + l.externalAuthCallbackFailed(w, r, authReq, nil, nil, err) return } session = &openid.Session{Provider: provider.(*openid.Provider), Code: data.Code} case domain.IDPTypeAzureAD: provider, err = l.azureProvider(r.Context(), identityProvider) if err != nil { - l.externalAuthFailed(w, r, authReq, nil, nil, err) + l.externalAuthCallbackFailed(w, r, authReq, nil, nil, err) return } session = &azuread.Session{Provider: provider.(*azuread.Provider), Code: data.Code} case domain.IDPTypeGitHub: provider, err = l.githubProvider(r.Context(), identityProvider) if err != nil { - l.externalAuthFailed(w, r, authReq, nil, nil, err) + l.externalAuthCallbackFailed(w, r, authReq, nil, nil, err) return } session = &oauth.Session{Provider: provider.(*github.Provider).Provider, Code: data.Code} case domain.IDPTypeGitHubEnterprise: provider, err = l.githubEnterpriseProvider(r.Context(), identityProvider) if err != nil { - l.externalAuthFailed(w, r, authReq, nil, nil, err) + l.externalAuthCallbackFailed(w, r, authReq, nil, nil, err) return } session = &oauth.Session{Provider: provider.(*github.Provider).Provider, Code: data.Code} case domain.IDPTypeGitLab: provider, err = l.gitlabProvider(r.Context(), identityProvider) if err != nil { - l.externalAuthFailed(w, r, authReq, nil, nil, err) + l.externalAuthCallbackFailed(w, r, authReq, nil, nil, err) return } session = &openid.Session{Provider: provider.(*gitlab.Provider).Provider, Code: data.Code} case domain.IDPTypeGitLabSelfHosted: provider, err = l.gitlabSelfHostedProvider(r.Context(), identityProvider) if err != nil { - l.externalAuthFailed(w, r, authReq, nil, nil, err) + l.externalAuthCallbackFailed(w, r, authReq, nil, nil, err) return } session = &openid.Session{Provider: provider.(*gitlab.Provider).Provider, Code: data.Code} case domain.IDPTypeGoogle: provider, err = l.googleProvider(r.Context(), identityProvider) if err != nil { - l.externalAuthFailed(w, r, authReq, nil, nil, err) + l.externalAuthCallbackFailed(w, r, authReq, nil, nil, err) return } session = &openid.Session{Provider: provider.(*google.Provider).Provider, Code: data.Code} case domain.IDPTypeApple: provider, err = l.appleProvider(r.Context(), identityProvider) if err != nil { - l.externalAuthFailed(w, r, authReq, nil, nil, err) + l.externalAuthCallbackFailed(w, r, authReq, nil, nil, err) return } session = &apple.Session{Session: &openid.Session{Provider: provider.(*apple.Provider).Provider, Code: data.Code}, UserFormValue: data.User} case domain.IDPTypeSAML: provider, err = l.samlProvider(r.Context(), identityProvider) if err != nil { - l.externalAuthFailed(w, r, authReq, nil, nil, err) + l.externalAuthCallbackFailed(w, r, authReq, nil, nil, err) return } session, err = saml.NewSession(provider.(*saml.Provider), authReq.SAMLRequestID, r) if err != nil { - l.externalAuthFailed(w, r, authReq, nil, nil, err) + l.externalAuthCallbackFailed(w, r, authReq, nil, nil, err) return } case domain.IDPTypeJWT, @@ -351,7 +353,7 @@ func (l *Login) handleExternalLoginCallback(w http.ResponseWriter, r *http.Reque domain.IDPTypeUnspecified: fallthrough default: - l.renderLogin(w, r, authReq, zerrors.ThrowInvalidArgument(nil, "LOGIN-SFefg", "Errors.ExternalIDP.IDPTypeNotImplemented")) + l.externalAuthFailed(w, r, authReq, zerrors.ThrowInvalidArgument(nil, "LOGIN-SFefg", "Errors.ExternalIDP.IDPTypeNotImplemented")) return } @@ -361,7 +363,7 @@ func (l *Login) handleExternalLoginCallback(w http.ResponseWriter, r *http.Reque "instance", authz.GetInstance(r.Context()).InstanceID(), "providerID", identityProvider.ID, ).WithError(err).Info("external authentication failed") - l.externalAuthFailed(w, r, authReq, tokens(session), user, err) + l.externalAuthCallbackFailed(w, r, authReq, tokens(session), user, err) return } l.handleExternalUserAuthenticated(w, r, authReq, identityProvider, session, user, l.renderNextStep) @@ -619,10 +621,6 @@ func (l *Login) autoCreateExternalUser(w http.ResponseWriter, r *http.Request, a // renderExternalNotFoundOption renders a page, where the user is able to edit the IDP data, // create a new externalUser of link to existing on (based on the IDP template) func (l *Login) renderExternalNotFoundOption(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, orgIAMPolicy *query.DomainPolicy, human *domain.Human, idpLink *domain.UserIDPLink, err error) { - var errID, errMessage string - if err != nil { - errID, errMessage = l.getErrorMessage(r, err) - } resourceOwner := determineResourceOwner(r.Context(), authReq) if orgIAMPolicy == nil { orgIAMPolicy, err = l.getOrgDomainPolicy(r, resourceOwner) @@ -656,7 +654,7 @@ func (l *Login) renderExternalNotFoundOption(w http.ResponseWriter, r *http.Requ translator := l.getTranslator(r.Context(), authReq) data := externalNotFoundOptionData{ - baseData: l.getBaseData(r, authReq, translator, "ExternalNotFound.Title", "ExternalNotFound.Description", errID, errMessage), + baseData: l.getBaseData(r, authReq, translator, "ExternalNotFound.Title", "ExternalNotFound.Description", err), externalNotFoundOptionFormData: externalNotFoundOptionFormData{ externalRegisterFormData: externalRegisterFormData{ Email: human.EmailAddress, @@ -1215,7 +1213,7 @@ func (l *Login) appendUserGrants(ctx context.Context, userGrants []*domain.UserG return nil } -func (l *Login) externalAuthFailed(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, tokens *oidc.Tokens[*oidc.IDTokenClaims], user idp.User, err error) { +func (l *Login) externalAuthCallbackFailed(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, tokens *oidc.Tokens[*oidc.IDTokenClaims], user idp.User, err error) { if authReq == nil { l.renderLogin(w, r, authReq, err) return @@ -1223,7 +1221,37 @@ func (l *Login) externalAuthFailed(w http.ResponseWriter, r *http.Request, authR if _, _, actionErr := l.runPostExternalAuthenticationActions(&domain.ExternalUser{}, tokens, authReq, r, user, err); actionErr != nil { logging.WithError(err).Error("both external user authentication and action post authentication failed") } - l.renderLogin(w, r, authReq, err) + l.externalAuthFailed(w, r, authReq, err) +} + +func (l *Login) externalAuthFailed(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, err error) { + if authReq == nil || authReq.LoginPolicy == nil || !authReq.LoginPolicy.AllowUsernamePassword || authReq.UserID == "" { + l.renderLogin(w, r, authReq, err) + return + } + authMethods, authMethodsError := l.query.ListUserAuthMethodTypes(setUserContext(r.Context(), authReq.UserID, ""), authReq.UserID, true, false, "") + if authMethodsError != nil { + logging.WithFields("userID", authReq.UserID).WithError(authMethodsError).Warn("unable to load user's auth methods for idp login error") + l.renderLogin(w, r, authReq, err) + return + } + passwordless := slices.Contains(authMethods.AuthMethodTypes, domain.UserAuthMethodTypePasswordless) + password := slices.Contains(authMethods.AuthMethodTypes, domain.UserAuthMethodTypePassword) + if !passwordless && !password { + l.renderLogin(w, r, authReq, err) + return + } + localAuthError := l.authRepo.RequestLocalAuth(setContext(r.Context(), authReq.UserOrgID), authReq.ID, authReq.AgentID) + if localAuthError != nil { + l.renderLogin(w, r, authReq, err) + return + } + err = WrapIdPError(err) + if passwordless { + l.renderPasswordlessVerification(w, r, authReq, password, err) + return + } + l.renderPassword(w, r, authReq, err) } // tokens extracts the oidc.Tokens for backwards compatibility of PostExternalAuthenticationActions @@ -1359,3 +1387,34 @@ func (l *Login) getUserLinks(ctx context.Context, userID, idpID string) (*query. }, nil, ) } + +// IdPError wraps an error from an external IDP to be able to distinguish it from other errors and to display it +// more prominent (popup style) . +// It's used if an error occurs during the login process with an external IDP and local authentication is allowed, +// respectively used as fallback. +type IdPError struct { + err *zerrors.ZitadelError +} + +func (e *IdPError) Error() string { + return e.err.Error() +} + +func (e *IdPError) Unwrap() error { + return e.err +} + +func (e *IdPError) Is(target error) bool { + _, ok := target.(*IdPError) + return ok +} + +func WrapIdPError(err error) *IdPError { + zErr := new(zerrors.ZitadelError) + id := "LOGIN-JWo3f" + // keep the original error id if there is one + if errors.As(err, &zErr) { + id = zErr.ID + } + return &IdPError{err: zerrors.CreateZitadelError(err, id, "Errors.User.ExternalIDP.LoginFailedSwitchLocal")} +} diff --git a/internal/api/ui/login/init_password_handler.go b/internal/api/ui/login/init_password_handler.go index b8c6d401c5..17ac13ff31 100644 --- a/internal/api/ui/login/init_password_handler.go +++ b/internal/api/ui/login/init_password_handler.go @@ -112,10 +112,6 @@ func (l *Login) resendPasswordSet(w http.ResponseWriter, r *http.Request, authRe } func (l *Login) renderInitPassword(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, userID, code string, err error) { - var errID, errMessage string - if err != nil { - errID, errMessage = l.getErrorMessage(r, err) - } if userID == "" && authReq != nil { userID = authReq.UserID } @@ -123,7 +119,7 @@ func (l *Login) renderInitPassword(w http.ResponseWriter, r *http.Request, authR translator := l.getTranslator(r.Context(), authReq) data := initPasswordData{ - baseData: l.getBaseData(r, authReq, translator, "InitPassword.Title", "InitPassword.Description", errID, errMessage), + baseData: l.getBaseData(r, authReq, translator, "InitPassword.Title", "InitPassword.Description", err), profileData: l.getProfileData(authReq), UserID: userID, Code: code, @@ -155,7 +151,7 @@ func (l *Login) renderInitPassword(w http.ResponseWriter, r *http.Request, authR func (l *Login) renderInitPasswordDone(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, orgID string) { translator := l.getTranslator(r.Context(), authReq) - data := l.getUserData(r, authReq, translator, "InitPasswordDone.Title", "InitPasswordDone.Description", "", "") + data := l.getUserData(r, authReq, translator, "InitPasswordDone.Title", "InitPasswordDone.Description", nil) if authReq == nil { l.customTexts(r.Context(), translator, orgID) } diff --git a/internal/api/ui/login/init_user_handler.go b/internal/api/ui/login/init_user_handler.go index 9a6d052dcd..fa4854a473 100644 --- a/internal/api/ui/login/init_user_handler.go +++ b/internal/api/ui/login/init_user_handler.go @@ -131,17 +131,13 @@ func (l *Login) resendUserInit(w http.ResponseWriter, r *http.Request, authReq * } func (l *Login) renderInitUser(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, userID, loginName string, code string, passwordSet bool, err error) { - var errID, errMessage string - if err != nil { - errID, errMessage = l.getErrorMessage(r, err) - } if authReq != nil { userID = authReq.UserID } translator := l.getTranslator(r.Context(), authReq) data := initUserData{ - baseData: l.getBaseData(r, authReq, translator, "InitUser.Title", "InitUser.Description", errID, errMessage), + baseData: l.getBaseData(r, authReq, translator, "InitUser.Title", "InitUser.Description", err), profileData: l.getProfileData(authReq), UserID: userID, Code: code, @@ -179,7 +175,7 @@ func (l *Login) renderInitUser(w http.ResponseWriter, r *http.Request, authReq * func (l *Login) renderInitUserDone(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, orgID string) { translator := l.getTranslator(r.Context(), authReq) - data := l.getUserData(r, authReq, translator, "InitUserDone.Title", "InitUserDone.Description", "", "") + data := l.getUserData(r, authReq, translator, "InitUserDone.Title", "InitUserDone.Description", nil) if authReq == nil { l.customTexts(r.Context(), translator, orgID) } diff --git a/internal/api/ui/login/invite_user_handler.go b/internal/api/ui/login/invite_user_handler.go index e083277c93..18ba502483 100644 --- a/internal/api/ui/login/invite_user_handler.go +++ b/internal/api/ui/login/invite_user_handler.go @@ -119,10 +119,6 @@ func (l *Login) resendUserInvite(w http.ResponseWriter, r *http.Request, authReq } func (l *Login) renderInviteUser(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, userID, orgID, loginName string, code string, err error) { - var errID, errMessage string - if err != nil { - errID, errMessage = l.getErrorMessage(r, err) - } if authReq != nil { userID = authReq.UserID orgID = authReq.UserOrgID @@ -130,7 +126,7 @@ func (l *Login) renderInviteUser(w http.ResponseWriter, r *http.Request, authReq translator := l.getTranslator(r.Context(), authReq) data := inviteUserData{ - baseData: l.getBaseData(r, authReq, translator, "InviteUser.Title", "InviteUser.Description", errID, errMessage), + baseData: l.getBaseData(r, authReq, translator, "InviteUser.Title", "InviteUser.Description", err), profileData: l.getProfileData(authReq), UserID: userID, Code: code, diff --git a/internal/api/ui/login/ldap_handler.go b/internal/api/ui/login/ldap_handler.go index 0fd47c5a6a..147a319523 100644 --- a/internal/api/ui/login/ldap_handler.go +++ b/internal/api/ui/login/ldap_handler.go @@ -30,13 +30,9 @@ func (l *Login) handleLDAP(w http.ResponseWriter, r *http.Request) { } func (l *Login) renderLDAPLogin(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, err error) { - var errID, errMessage string - if err != nil { - errID, errMessage = l.getErrorMessage(r, err) - } temp := l.renderer.Templates[tmplLDAPLogin] translator := l.getTranslator(r.Context(), authReq) - data := l.getUserData(r, authReq, translator, "Login.Title", "Login.Description", errID, errMessage) + data := l.getUserData(r, authReq, translator, "Login.Title", "Login.Description", err) l.renderer.RenderTemplate(w, r, translator, temp, data, nil) } diff --git a/internal/api/ui/login/link_users_handler.go b/internal/api/ui/login/link_users_handler.go index c720559084..0b0803f8a1 100644 --- a/internal/api/ui/login/link_users_handler.go +++ b/internal/api/ui/login/link_users_handler.go @@ -18,11 +18,7 @@ func (l *Login) linkUsers(w http.ResponseWriter, r *http.Request, authReq *domai } func (l *Login) renderLinkUsersDone(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, err error) { - var errType, errMessage string - if err != nil { - errType, errMessage = l.getErrorMessage(r, err) - } translator := l.getTranslator(r.Context(), authReq) - data := l.getUserData(r, authReq, translator, "LinkingUsersDone.Title", "LinkingUsersDone.Description", errType, errMessage) + data := l.getUserData(r, authReq, translator, "LinkingUsersDone.Title", "LinkingUsersDone.Description", err) l.renderer.RenderTemplate(w, r, translator, l.renderer.Templates[tmplLinkUsersDone], data, nil) } diff --git a/internal/api/ui/login/login_handler.go b/internal/api/ui/login/login_handler.go index 059048eecb..729bd1955b 100644 --- a/internal/api/ui/login/login_handler.go +++ b/internal/api/ui/login/login_handler.go @@ -91,16 +91,12 @@ func (l *Login) handleLoginNameCheck(w http.ResponseWriter, r *http.Request) { } func (l *Login) renderLogin(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, err error) { - var errID, errMessage string - if err != nil { - errID, errMessage = l.getErrorMessage(r, err) - } if err == nil && singleIDPAllowed(authReq) { l.handleIDP(w, r, authReq, authReq.AllowedExternalIDPs[0].IDPConfigID) return } translator := l.getTranslator(r.Context(), authReq) - data := l.getUserData(r, authReq, translator, "Login.Title", "Login.Description", errID, errMessage) + data := l.getUserData(r, authReq, translator, "Login.Title", "Login.Description", err) funcs := map[string]interface{}{ "hasUsernamePasswordLogin": func() bool { return authReq != nil && authReq.LoginPolicy != nil && authReq.LoginPolicy.AllowUsernamePassword diff --git a/internal/api/ui/login/login_success_handler.go b/internal/api/ui/login/login_success_handler.go index 00f29becfd..a18a3a2d5c 100644 --- a/internal/api/ui/login/login_success_handler.go +++ b/internal/api/ui/login/login_success_handler.go @@ -37,13 +37,9 @@ func (l *Login) handleLoginSuccess(w http.ResponseWriter, r *http.Request) { } func (l *Login) renderSuccessAndCallback(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, err error) { - var errID, errMessage string - if err != nil { - errID, errMessage = l.getErrorMessage(r, err) - } translator := l.getTranslator(r.Context(), authReq) data := loginSuccessData{ - userData: l.getUserData(r, authReq, translator, "LoginSuccess.Title", "", errID, errMessage), + userData: l.getUserData(r, authReq, translator, "LoginSuccess.Title", "", err), } if authReq != nil { data.RedirectURI, err = l.authRequestCallback(r.Context(), authReq) diff --git a/internal/api/ui/login/logout_handler.go b/internal/api/ui/login/logout_handler.go index e270cd5541..9596f477af 100644 --- a/internal/api/ui/login/logout_handler.go +++ b/internal/api/ui/login/logout_handler.go @@ -14,6 +14,6 @@ func (l *Login) handleLogoutDone(w http.ResponseWriter, r *http.Request) { func (l *Login) renderLogoutDone(w http.ResponseWriter, r *http.Request) { translator := l.getTranslator(r.Context(), nil) - data := l.getUserData(r, nil, translator, "LogoutDone.Title", "LogoutDone.Description", "", "") + data := l.getUserData(r, nil, translator, "LogoutDone.Title", "LogoutDone.Description", nil) l.renderer.RenderTemplate(w, r, translator, l.renderer.Templates[tmplLogoutDone], data, nil) } diff --git a/internal/api/ui/login/mail_verify_handler.go b/internal/api/ui/login/mail_verify_handler.go index 864ff76dd2..071fe6539d 100644 --- a/internal/api/ui/login/mail_verify_handler.go +++ b/internal/api/ui/login/mail_verify_handler.go @@ -145,17 +145,13 @@ func (l *Login) checkMailCode(w http.ResponseWriter, r *http.Request, authReq *d } func (l *Login) renderMailVerification(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, userID, code string, passwordInit bool, err error) { - var errID, errMessage string - if err != nil { - errID, errMessage = l.getErrorMessage(r, err) - } if userID == "" && authReq != nil { userID = authReq.UserID } translator := l.getTranslator(r.Context(), authReq) data := mailVerificationData{ - baseData: l.getBaseData(r, authReq, translator, "EmailVerification.Title", "EmailVerification.Description", errID, errMessage), + baseData: l.getBaseData(r, authReq, translator, "EmailVerification.Title", "EmailVerification.Description", err), UserID: userID, profileData: l.getProfileData(authReq), Code: code, @@ -191,7 +187,7 @@ func (l *Login) renderMailVerification(w http.ResponseWriter, r *http.Request, a func (l *Login) renderMailVerified(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, orgID string) { translator := l.getTranslator(r.Context(), authReq) data := mailVerificationData{ - baseData: l.getBaseData(r, authReq, translator, "EmailVerificationDone.Title", "EmailVerificationDone.Description", "", ""), + baseData: l.getBaseData(r, authReq, translator, "EmailVerificationDone.Title", "EmailVerificationDone.Description", nil), profileData: l.getProfileData(authReq), } if authReq == nil { diff --git a/internal/api/ui/login/mfa_init_done_handler.go b/internal/api/ui/login/mfa_init_done_handler.go index 437fde29f4..ae4bab69ea 100644 --- a/internal/api/ui/login/mfa_init_done_handler.go +++ b/internal/api/ui/login/mfa_init_done_handler.go @@ -14,9 +14,8 @@ type mfaInitDoneData struct { } func (l *Login) renderMFAInitDone(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, data *mfaDoneData) { - var errType, errMessage string translator := l.getTranslator(r.Context(), authReq) - data.baseData = l.getBaseData(r, authReq, translator, "InitMFADone.Title", "InitMFADone.Description", errType, errMessage) + data.baseData = l.getBaseData(r, authReq, translator, "InitMFADone.Title", "InitMFADone.Description", nil) data.profileData = l.getProfileData(authReq) l.renderer.RenderTemplate(w, r, translator, l.renderer.Templates[tmplMFAInitDone], data, nil) } diff --git a/internal/api/ui/login/mfa_init_sms.go b/internal/api/ui/login/mfa_init_sms.go index 03f2c32014..048677f0f4 100644 --- a/internal/api/ui/login/mfa_init_sms.go +++ b/internal/api/ui/login/mfa_init_sms.go @@ -53,12 +53,8 @@ func (l *Login) handleRegisterOTPSMS(w http.ResponseWriter, r *http.Request, aut } func (l *Login) renderRegisterSMS(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, data *smsInitData, err error) { - var errID, errMessage string - if err != nil { - errID, errMessage = l.getErrorMessage(r, err) - } translator := l.getTranslator(r.Context(), authReq) - data.baseData = l.getBaseData(r, authReq, translator, "InitMFAOTP.Title", "InitMFAOTP.Description", errID, errMessage) + data.baseData = l.getBaseData(r, authReq, translator, "InitMFAOTP.Title", "InitMFAOTP.Description", err) data.profileData = l.getProfileData(authReq) data.MFAType = domain.MFATypeOTPSMS l.renderer.RenderTemplate(w, r, translator, l.renderer.Templates[tmplMFASMSInit], data, nil) diff --git a/internal/api/ui/login/mfa_init_u2f.go b/internal/api/ui/login/mfa_init_u2f.go index c84948796c..0e75bd1b69 100644 --- a/internal/api/ui/login/mfa_init_u2f.go +++ b/internal/api/ui/login/mfa_init_u2f.go @@ -18,21 +18,18 @@ type u2fInitData struct { } func (l *Login) renderRegisterU2F(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, err error) { - var errID, errMessage, credentialData string + var credentialData string var u2f *domain.WebAuthNToken if err == nil { u2f, err = l.command.HumanAddU2FSetup(setUserContext(r.Context(), authReq.UserID, authReq.UserOrgID), authReq.UserID, authReq.UserOrgID) } - if err != nil { - errID, errMessage = l.getErrorMessage(r, err) - } if u2f != nil { credentialData = base64.RawURLEncoding.EncodeToString(u2f.CredentialCreationData) } translator := l.getTranslator(r.Context(), authReq) data := &u2fInitData{ webAuthNData: webAuthNData{ - userData: l.getUserData(r, authReq, translator, "InitMFAU2F.Title", "InitMFAU2F.Description", errID, errMessage), + userData: l.getUserData(r, authReq, translator, "InitMFAU2F.Title", "InitMFAU2F.Description", err), CredentialCreationData: credentialData, }, MFAType: domain.MFATypeU2F, diff --git a/internal/api/ui/login/mfa_init_verify_handler.go b/internal/api/ui/login/mfa_init_verify_handler.go index cd6a9091e2..e3488d391c 100644 --- a/internal/api/ui/login/mfa_init_verify_handler.go +++ b/internal/api/ui/login/mfa_init_verify_handler.go @@ -66,12 +66,8 @@ func (l *Login) handleOTPVerify(w http.ResponseWriter, r *http.Request, authReq } func (l *Login) renderMFAInitVerify(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, data *mfaVerifyData, err error) { - var errID, errMessage string - if err != nil { - errID, errMessage = l.getErrorMessage(r, err) - } translator := l.getTranslator(r.Context(), authReq) - data.baseData = l.getBaseData(r, authReq, translator, "InitMFAOTP.Title", "InitMFAOTP.Description", errID, errMessage) + data.baseData = l.getBaseData(r, authReq, translator, "InitMFAOTP.Title", "InitMFAOTP.Description", err) data.profileData = l.getProfileData(authReq) if data.MFAType == domain.MFATypeTOTP { code, err := generateQrCode(data.totpData.Url) diff --git a/internal/api/ui/login/mfa_prompt_handler.go b/internal/api/ui/login/mfa_prompt_handler.go index ce1b7240ec..ca318741b7 100644 --- a/internal/api/ui/login/mfa_prompt_handler.go +++ b/internal/api/ui/login/mfa_prompt_handler.go @@ -49,13 +49,9 @@ func (l *Login) handleMFAPromptSelection(w http.ResponseWriter, r *http.Request) } func (l *Login) renderMFAPrompt(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, mfaPromptData *domain.MFAPromptStep, err error) { - var errID, errMessage string - if err != nil { - errID, errMessage = l.getErrorMessage(r, err) - } translator := l.getTranslator(r.Context(), authReq) data := mfaData{ - baseData: l.getBaseData(r, authReq, translator, "InitMFAPrompt.Title", "InitMFAPrompt.Description", errID, errMessage), + baseData: l.getBaseData(r, authReq, translator, "InitMFAPrompt.Title", "InitMFAPrompt.Description", err), profileData: l.getProfileData(authReq), } diff --git a/internal/api/ui/login/mfa_verify_handler.go b/internal/api/ui/login/mfa_verify_handler.go index cfffc6fced..34832413cf 100644 --- a/internal/api/ui/login/mfa_verify_handler.go +++ b/internal/api/ui/login/mfa_verify_handler.go @@ -62,12 +62,8 @@ func (l *Login) renderMFAVerify(w http.ResponseWriter, r *http.Request, authReq } func (l *Login) renderMFAVerifySelected(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, verificationStep *domain.MFAVerificationStep, selectedProvider domain.MFAType, err error) { - var errID, errMessage string - if err != nil { - errID, errMessage = l.getErrorMessage(r, err) - } translator := l.getTranslator(r.Context(), authReq) - data := l.getUserData(r, authReq, translator, "", "", errID, errMessage) + data := l.getUserData(r, authReq, translator, "", "", err) if verificationStep == nil { l.renderError(w, r, authReq, err) return diff --git a/internal/api/ui/login/mfa_verify_otp_handler.go b/internal/api/ui/login/mfa_verify_otp_handler.go index fb77bbcba9..bd09a7652b 100644 --- a/internal/api/ui/login/mfa_verify_otp_handler.go +++ b/internal/api/ui/login/mfa_verify_otp_handler.go @@ -61,13 +61,9 @@ func (l *Login) handleOTPVerification(w http.ResponseWriter, r *http.Request, au } func (l *Login) renderOTPVerification(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, providers []domain.MFAType, selectedProvider domain.MFAType, err error) { - var errID, errMessage string - if err != nil { - errID, errMessage = l.getErrorMessage(r, err) - } translator := l.getTranslator(r.Context(), authReq) data := &mfaOTPData{ - userData: l.getUserData(r, authReq, translator, "VerifyMFAU2F.Title", "VerifyMFAU2F.Description", errID, errMessage), + userData: l.getUserData(r, authReq, translator, "VerifyMFAU2F.Title", "VerifyMFAU2F.Description", err), MFAProviders: removeSelectedProviderFromList(providers, selectedProvider), SelectedProvider: selectedProvider, } diff --git a/internal/api/ui/login/mfa_verify_u2f_handler.go b/internal/api/ui/login/mfa_verify_u2f_handler.go index 7873468616..8541c043e4 100644 --- a/internal/api/ui/login/mfa_verify_u2f_handler.go +++ b/internal/api/ui/login/mfa_verify_u2f_handler.go @@ -24,22 +24,19 @@ type mfaU2FFormData struct { } func (l *Login) renderU2FVerification(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, providers []domain.MFAType, err error) { - var errID, errMessage, credentialData string + var credentialData string var webAuthNLogin *domain.WebAuthNLogin if err == nil { userAgentID, _ := http_mw.UserAgentIDFromCtx(r.Context()) webAuthNLogin, err = l.authRepo.BeginMFAU2FLogin(setContext(r.Context(), authReq.UserOrgID), authReq.UserID, authReq.UserOrgID, authReq.ID, userAgentID) } - if err != nil { - errID, errMessage = l.getErrorMessage(r, err) - } if webAuthNLogin != nil { credentialData = base64.RawURLEncoding.EncodeToString(webAuthNLogin.CredentialAssertionData) } translator := l.getTranslator(r.Context(), authReq) data := &mfaU2FData{ webAuthNData: webAuthNData{ - userData: l.getUserData(r, authReq, translator, "VerifyMFAU2F.Title", "VerifyMFAU2F.Description", errID, errMessage), + userData: l.getUserData(r, authReq, translator, "VerifyMFAU2F.Title", "VerifyMFAU2F.Description", err), CredentialCreationData: credentialData, }, MFAProviders: providers, diff --git a/internal/api/ui/login/password_handler.go b/internal/api/ui/login/password_handler.go index 026963bbde..a6e9199ff7 100644 --- a/internal/api/ui/login/password_handler.go +++ b/internal/api/ui/login/password_handler.go @@ -15,12 +15,8 @@ type passwordFormData struct { } func (l *Login) renderPassword(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, err error) { - var errID, errMessage string - if err != nil { - errID, errMessage = l.getErrorMessage(r, err) - } translator := l.getTranslator(r.Context(), authReq) - data := l.getUserData(r, authReq, translator, "Password.Title", "Password.Description", errID, errMessage) + data := l.getUserData(r, authReq, translator, "Password.Title", "Password.Description", err) funcs := map[string]interface{}{ "showPasswordReset": func() bool { if authReq.LoginPolicy != nil { diff --git a/internal/api/ui/login/password_reset_handler.go b/internal/api/ui/login/password_reset_handler.go index f4f98806c7..5bdee7904c 100644 --- a/internal/api/ui/login/password_reset_handler.go +++ b/internal/api/ui/login/password_reset_handler.go @@ -30,11 +30,7 @@ func (l *Login) handlePasswordReset(w http.ResponseWriter, r *http.Request) { } func (l *Login) renderPasswordResetDone(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, err error) { - var errID, errMessage string - if err != nil { - errID, errMessage = l.getErrorMessage(r, err) - } translator := l.getTranslator(r.Context(), authReq) - data := l.getUserData(r, authReq, translator, "PasswordResetDone.Title", "PasswordResetDone.Description", errID, errMessage) + data := l.getUserData(r, authReq, translator, "PasswordResetDone.Title", "PasswordResetDone.Description", err) l.renderer.RenderTemplate(w, r, translator, l.renderer.Templates[tmplPasswordResetDone], data, nil) } diff --git a/internal/api/ui/login/passwordless_login_handler.go b/internal/api/ui/login/passwordless_login_handler.go index 52b9d06fed..d64ad2c3c1 100644 --- a/internal/api/ui/login/passwordless_login_handler.go +++ b/internal/api/ui/login/passwordless_login_handler.go @@ -2,6 +2,7 @@ package login import ( "encoding/base64" + "errors" "net/http" "github.com/zitadel/zitadel/internal/domain" @@ -22,13 +23,15 @@ type passwordlessFormData struct { } func (l *Login) renderPasswordlessVerification(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, passwordSet bool, err error) { - var errID, errMessage, credentialData string + var credentialData string var webAuthNLogin *domain.WebAuthNLogin - if err == nil { - webAuthNLogin, err = l.authRepo.BeginPasswordlessLogin(setContext(r.Context(), authReq.UserOrgID), authReq.UserID, authReq.UserOrgID, authReq.ID, authReq.AgentID) - } - if err != nil { - errID, errMessage = l.getErrorMessage(r, err) + if err == nil || errors.Is(err, &IdPError{}) { // make sure we still proceed with the webauthn login even if the idp login failed + var creationErr error + webAuthNLogin, creationErr = l.authRepo.BeginPasswordlessLogin(setContext(r.Context(), authReq.UserOrgID), authReq.UserID, authReq.UserOrgID, authReq.ID, authReq.AgentID) + // and only overwrite the error if the webauthn creation failed + if creationErr != nil { + err = creationErr + } } if webAuthNLogin != nil { credentialData = base64.RawURLEncoding.EncodeToString(webAuthNLogin.CredentialAssertionData) @@ -39,7 +42,7 @@ func (l *Login) renderPasswordlessVerification(w http.ResponseWriter, r *http.Re translator := l.getTranslator(r.Context(), authReq) data := &passwordlessData{ webAuthNData{ - userData: l.getUserData(r, authReq, translator, "Passwordless.Title", "Passwordless.Description", errID, errMessage), + userData: l.getUserData(r, authReq, translator, "Passwordless.Title", "Passwordless.Description", err), CredentialCreationData: credentialData, }, passwordSet, diff --git a/internal/api/ui/login/passwordless_prompt_handler.go b/internal/api/ui/login/passwordless_prompt_handler.go index ee70b76126..36a3ede71e 100644 --- a/internal/api/ui/login/passwordless_prompt_handler.go +++ b/internal/api/ui/login/passwordless_prompt_handler.go @@ -27,13 +27,9 @@ func (l *Login) handlePasswordlessPrompt(w http.ResponseWriter, r *http.Request) } func (l *Login) renderPasswordlessPrompt(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, err error) { - var errID, errMessage string - if err != nil { - errID, errMessage = l.getErrorMessage(r, err) - } translator := l.getTranslator(r.Context(), authReq) data := &passwordlessPromptData{ - userData: l.getUserData(r, authReq, translator, "PasswordlessPrompt.Title", "PasswordlessPrompt.Description", errID, errMessage), + userData: l.getUserData(r, authReq, translator, "PasswordlessPrompt.Title", "PasswordlessPrompt.Description", err), } l.renderer.RenderTemplate(w, r, translator, l.renderer.Templates[tmplPasswordlessPrompt], data, nil) } diff --git a/internal/api/ui/login/passwordless_registration_handler.go b/internal/api/ui/login/passwordless_registration_handler.go index 976a9277b2..782d62f1fe 100644 --- a/internal/api/ui/login/passwordless_registration_handler.go +++ b/internal/api/ui/login/passwordless_registration_handler.go @@ -78,7 +78,7 @@ func (l *Login) handlePasswordlessRegistration(w http.ResponseWriter, r *http.Re } func (l *Login) renderPasswordlessRegistration(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, userID, orgID, codeID, code string, requestedPlatformType authPlatform, err error) { - var errID, errMessage, credentialData string + var credentialData string var disabled bool if authReq != nil { userID = authReq.UserID @@ -93,7 +93,6 @@ func (l *Login) renderPasswordlessRegistration(w http.ResponseWriter, r *http.Re } } if err != nil { - errID, errMessage = l.getErrorMessage(r, err) disabled = true } if webAuthNToken != nil { @@ -102,7 +101,7 @@ func (l *Login) renderPasswordlessRegistration(w http.ResponseWriter, r *http.Re translator := l.getTranslator(r.Context(), authReq) data := &passwordlessRegistrationData{ webAuthNData{ - userData: l.getUserData(r, authReq, translator, "PasswordlessRegistration.Title", "PasswordlessRegistration.Description", errID, errMessage), + userData: l.getUserData(r, authReq, translator, "PasswordlessRegistration.Title", "PasswordlessRegistration.Description", err), CredentialCreationData: credentialData, }, code, @@ -185,13 +184,9 @@ func (l *Login) checkPasswordlessRegistration(w http.ResponseWriter, r *http.Req } func (l *Login) renderPasswordlessRegistrationDone(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, orgID string, err error) { - var errID, errMessage string - if err != nil { - errID, errMessage = l.getErrorMessage(r, err) - } translator := l.getTranslator(r.Context(), authReq) data := passwordlessRegistrationDoneDate{ - userData: l.getUserData(r, authReq, translator, "PasswordlessRegistrationDone.Title", "PasswordlessRegistrationDone.Description", errID, errMessage), + userData: l.getUserData(r, authReq, translator, "PasswordlessRegistrationDone.Title", "PasswordlessRegistrationDone.Description", err), HideNextButton: authReq == nil, } if authReq == nil { diff --git a/internal/api/ui/login/register_handler.go b/internal/api/ui/login/register_handler.go index 89e0eec7b3..bd5629c432 100644 --- a/internal/api/ui/login/register_handler.go +++ b/internal/api/ui/login/register_handler.go @@ -142,10 +142,6 @@ func (l *Login) handleRegisterCheck(w http.ResponseWriter, r *http.Request) { } func (l *Login) renderRegister(w http.ResponseWriter, r *http.Request, authRequest *domain.AuthRequest, formData *registerFormData, err error) { - var errID, errMessage string - if err != nil { - errID, errMessage = l.getErrorMessage(r, err) - } translator := l.getTranslator(r.Context(), authRequest) if formData == nil { formData = new(registerFormData) @@ -156,7 +152,7 @@ func (l *Login) renderRegister(w http.ResponseWriter, r *http.Request, authReque resourceOwner := determineResourceOwner(r.Context(), authRequest) data := registerData{ - baseData: l.getBaseData(r, authRequest, translator, "RegistrationUser.Title", "RegistrationUser.Description", errID, errMessage), + baseData: l.getBaseData(r, authRequest, translator, "RegistrationUser.Title", "RegistrationUser.Description", err), registerFormData: *formData, } diff --git a/internal/api/ui/login/register_option_handler.go b/internal/api/ui/login/register_option_handler.go index 7d88f76c6c..31270c0442 100644 --- a/internal/api/ui/login/register_option_handler.go +++ b/internal/api/ui/login/register_option_handler.go @@ -33,10 +33,6 @@ func (l *Login) handleRegisterOption(w http.ResponseWriter, r *http.Request) { } func (l *Login) renderRegisterOption(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, err error) { - var errID, errMessage string - if err != nil { - errID, errMessage = l.getErrorMessage(r, err) - } allowed := registrationAllowed(authReq) externalAllowed := externalRegistrationAllowed(authReq) if err == nil { @@ -54,7 +50,7 @@ func (l *Login) renderRegisterOption(w http.ResponseWriter, r *http.Request, aut } translator := l.getTranslator(r.Context(), authReq) data := registerOptionData{ - baseData: l.getBaseData(r, authReq, translator, "RegisterOption.Title", "RegisterOption.Description", errID, errMessage), + baseData: l.getBaseData(r, authReq, translator, "RegisterOption.Title", "RegisterOption.Description", err), } funcs := map[string]interface{}{ "hasRegistration": func() bool { diff --git a/internal/api/ui/login/register_org_handler.go b/internal/api/ui/login/register_org_handler.go index acb032d8f1..58a49f1d08 100644 --- a/internal/api/ui/login/register_org_handler.go +++ b/internal/api/ui/login/register_org_handler.go @@ -97,16 +97,12 @@ func (l *Login) handleRegisterOrgCheck(w http.ResponseWriter, r *http.Request) { } func (l *Login) renderRegisterOrg(w http.ResponseWriter, r *http.Request, authRequest *domain.AuthRequest, formData *registerOrgFormData, err error) { - var errID, errMessage string - if err != nil { - errID, errMessage = l.getErrorMessage(r, err) - } if formData == nil { formData = new(registerOrgFormData) } translator := l.getTranslator(r.Context(), authRequest) data := registerOrgData{ - baseData: l.getBaseData(r, authRequest, translator, "RegistrationOrg.Title", "RegistrationOrg.Description", errID, errMessage), + baseData: l.getBaseData(r, authRequest, translator, "RegistrationOrg.Title", "RegistrationOrg.Description", err), registerOrgFormData: *formData, } pwPolicy := l.getPasswordComplexityPolicy(r, "0") diff --git a/internal/api/ui/login/renderer.go b/internal/api/ui/login/renderer.go index cb05f78323..79fc2dcf0d 100644 --- a/internal/api/ui/login/renderer.go +++ b/internal/api/ui/login/renderer.go @@ -341,7 +341,6 @@ func (l *Login) chooseNextStep(w http.ResponseWriter, r *http.Request, authReq * } func (l *Login) renderInternalError(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, err error) { - var msg string if err != nil { log := logging.WithError(err) if authReq != nil { @@ -352,17 +351,15 @@ func (l *Login) renderInternalError(w http.ResponseWriter, r *http.Request, auth } else { log.Info() } - - _, msg = l.getErrorMessage(r, err) } translator := l.getTranslator(r.Context(), authReq) - data := l.getBaseData(r, authReq, translator, "Errors.Internal", "", "Internal", msg) + data := l.getBaseData(r, authReq, translator, "Errors.Internal", "", err) l.renderer.RenderTemplate(w, r, translator, l.renderer.Templates[tmplError], data, nil) } -func (l *Login) getUserData(r *http.Request, authReq *domain.AuthRequest, translator *i18n.Translator, titleI18nKey string, descriptionI18nKey string, errType, errMessage string) userData { +func (l *Login) getUserData(r *http.Request, authReq *domain.AuthRequest, translator *i18n.Translator, titleI18nKey string, descriptionI18nKey string, err error) userData { userData := userData{ - baseData: l.getBaseData(r, authReq, translator, titleI18nKey, descriptionI18nKey, errType, errMessage), + baseData: l.getBaseData(r, authReq, translator, titleI18nKey, descriptionI18nKey, err), profileData: l.getProfileData(authReq), } if authReq != nil && authReq.LinkingUsers != nil { @@ -371,7 +368,7 @@ func (l *Login) getUserData(r *http.Request, authReq *domain.AuthRequest, transl return userData } -func (l *Login) getBaseData(r *http.Request, authReq *domain.AuthRequest, translator *i18n.Translator, titleI18nKey string, descriptionI18nKey string, errType, errMessage string) baseData { +func (l *Login) getBaseData(r *http.Request, authReq *domain.AuthRequest, translator *i18n.Translator, titleI18nKey string, descriptionI18nKey string, err error) baseData { title := "" if titleI18nKey != "" { title = translator.LocalizeWithoutArgs(titleI18nKey) @@ -383,10 +380,16 @@ func (l *Login) getBaseData(r *http.Request, authReq *domain.AuthRequest, transl } lang, _ := l.renderer.ReqLang(translator, r).Base() + var errID, errMessage string + var errPopup bool + if err != nil { + errID, errMessage, errPopup = l.getErrorMessage(r, err) + } baseData := baseData{ errorData: errorData{ - ErrID: errType, + ErrID: errID, ErrMessage: errMessage, + ErrPopup: errPopup, }, Lang: lang.String(), Title: title, @@ -482,14 +485,17 @@ func (l *Login) setLinksOnBaseData(baseData baseData, privacyPolicy *domain.Priv return baseData } -func (l *Login) getErrorMessage(r *http.Request, err error) (errID, errMsg string) { +func (l *Login) getErrorMessage(r *http.Request, err error) (errID, errMsg string, popup bool) { + idpErr := new(IdPError) + if errors.Is(err, idpErr) { + popup = true + } caosErr := new(zerrors.ZitadelError) if errors.As(err, &caosErr) { - localized := l.renderer.LocalizeFromRequest(l.getTranslator(r.Context(), nil), r, caosErr.Message, nil) - return caosErr.ID, localized - + localized := l.renderer.LocalizeFromRequest(l.getTranslator(r.Context(), nil), r, caosErr.Message, map[string]interface{}{"Details": caosErr.Parent}) + return caosErr.ID, localized, popup } - return "", err.Error() + return "", err.Error(), popup } func (l *Login) getTheme(r *http.Request) string { @@ -662,6 +668,7 @@ type baseData struct { type errorData struct { ErrID string ErrMessage string + ErrPopup bool } type userData struct { diff --git a/internal/api/ui/login/select_user_handler.go b/internal/api/ui/login/select_user_handler.go index 98c3993376..b15366baa1 100644 --- a/internal/api/ui/login/select_user_handler.go +++ b/internal/api/ui/login/select_user_handler.go @@ -27,7 +27,7 @@ func (l *Login) renderUserSelection(w http.ResponseWriter, r *http.Request, auth descriptionI18nKey = "SelectAccount.DescriptionLinking" } data := userSelectionData{ - baseData: l.getBaseData(r, authReq, translator, titleI18nKey, descriptionI18nKey, "", ""), + baseData: l.getBaseData(r, authReq, translator, titleI18nKey, descriptionI18nKey, nil), Users: selectionData.Users, Linking: linking, } diff --git a/internal/api/ui/login/static/i18n/bg.yaml b/internal/api/ui/login/static/i18n/bg.yaml index ad308b859d..be0b1d7f14 100644 --- a/internal/api/ui/login/static/i18n/bg.yaml +++ b/internal/api/ui/login/static/i18n/bg.yaml @@ -493,6 +493,10 @@ Errors: CreationNotAllowed: Създаването на нов потребител не е разрешено на този доставчик LinkingNotAllowed: Свързването на потребител не е разрешено на този доставчик NoOptionAllowed: Нито създаване, нито свързване е разрешено за този доставчик. Моля, свържете се с администратора. + LoginFailedSwitchLocal: | + Вход в външен доставчик на идентификация е неуспешен. Връщане към локален вход. + + Подробности за грешката: {{.Details}} GrantRequired: 'Влизането не е възможно. ' ProjectRequired: 'Влизането не е възможно. ' IdentityProvider: diff --git a/internal/api/ui/login/static/i18n/cs.yaml b/internal/api/ui/login/static/i18n/cs.yaml index 032302e3b8..f362add6f3 100644 --- a/internal/api/ui/login/static/i18n/cs.yaml +++ b/internal/api/ui/login/static/i18n/cs.yaml @@ -505,6 +505,10 @@ Errors: CreationNotAllowed: Vytvoření nového uživatele není na tomto poskytovateli povoleno LinkingNotAllowed: Propojení uživatele není na tomto poskytovateli povoleno NoOptionAllowed: Ani vytvoření, ani propojení není povoleno pro tohoto poskytovatele. Obraťte se na svého správce. + LoginFailedSwitchLocal: | + Přihlášení u externího poskytovatele identit selhalo. Vracíme se k místnímu přihlášení. + + Podrobnosti o chybě: {{.Details}} GrantRequired: Přihlášení není možné. Uživatel musí mít alespoň jeden oprávnění na aplikaci. Prosím, kontaktujte svého správce. ProjectRequired: Přihlášení není možné. Organizace uživatele musí být přidělena k projektu. Prosím, kontaktujte svého správce. IdentityProvider: diff --git a/internal/api/ui/login/static/i18n/de.yaml b/internal/api/ui/login/static/i18n/de.yaml index 28f4d00a88..4e6782fcb8 100644 --- a/internal/api/ui/login/static/i18n/de.yaml +++ b/internal/api/ui/login/static/i18n/de.yaml @@ -504,6 +504,10 @@ Errors: CreationNotAllowed: Erstellen eines neuen Benutzers mit diesem Provider ist nicht erlaubt LinkingNotAllowed: Verknüpfen eines Benutzers mit diesem Provider ist nicht erlaubt NoOptionAllowed: Weder Erstellung noch Verknüpfung ist für diesen Provider erlaubt. Bitte wenden Sie sich an Ihren Administrator. + LoginFailedSwitchLocal: | + Anmeldung beim externen Identitätsanbieter fehlgeschlagen. Zurück zur lokalen Anmeldung. + + Fehlerdetails: {{.Details}} GrantRequired: Die Anmeldung an diese Applikation ist nicht möglich. Der Benutzer benötigt mindestens eine Berechtigung an der Applikation. Bitte wende dich an deinen Administrator. ProjectRequired: Die Anmeldung an dieser Applikation ist nicht möglich. Die Organisation des Benutzer benötigt Berechtigung auf das Projekt. Bitte wende dich an deinen Administrator. IdentityProvider: diff --git a/internal/api/ui/login/static/i18n/en.yaml b/internal/api/ui/login/static/i18n/en.yaml index 6c58b11257..bdf42ae57f 100644 --- a/internal/api/ui/login/static/i18n/en.yaml +++ b/internal/api/ui/login/static/i18n/en.yaml @@ -505,6 +505,10 @@ Errors: CreationNotAllowed: Creation of a new user is not allowed on this provider LinkingNotAllowed: Linking of a user is not allowed on this provider NoOptionAllowed: Neither creation of linking is allowed on this provider. Please contact your administrator. + LoginFailedSwitchLocal: | + Login at External IDP failed. Falling back to local login. + + Error details: {{.Details}} GrantRequired: Login not possible. The user is required to have at least one grant on the application. Please contact your administrator. ProjectRequired: Login not possible. The organization of the user must be granted to the project. Please contact your administrator. IdentityProvider: diff --git a/internal/api/ui/login/static/i18n/es.yaml b/internal/api/ui/login/static/i18n/es.yaml index de57fdcd85..c6aaac6bf0 100644 --- a/internal/api/ui/login/static/i18n/es.yaml +++ b/internal/api/ui/login/static/i18n/es.yaml @@ -488,6 +488,10 @@ Errors: CreationNotAllowed: La creación de un nuevo usuario no está permitida para este proveedor LinkingNotAllowed: La vinculación de un usuario no está permitida para este proveedor NoOptionAllowed: Ni la creación ni la vinculación están permitidas en este proveedor. Póngase en contacto con su administrador. + LoginFailedSwitchLocal: | + Error al iniciar sesión en el proveedor de identidad externo. Volviendo al inicio de sesión local. + + Detalles del error: {{.Details}} GrantRequired: El inicio de sesión no es posible. Se requiere que el usuario tenga al menos una concesión sobre la aplicación. Por favor contacta con tu administrador. ProjectRequired: El inicio de sesión no es posible. La organización del usuario debe tener el acceso concedido para el proyecto. Por favor contacta con tu administrador. IdentityProvider: diff --git a/internal/api/ui/login/static/i18n/fr.yaml b/internal/api/ui/login/static/i18n/fr.yaml index 8534085ae9..83dd64d147 100644 --- a/internal/api/ui/login/static/i18n/fr.yaml +++ b/internal/api/ui/login/static/i18n/fr.yaml @@ -506,6 +506,10 @@ Errors: CreationNotAllowed: La création d'un nouvel utilisateur n'est pas autorisée sur ce fournisseur. LinkingNotAllowed: La création d'un lien vers un utilisateur n'est pas autorisée pour ce fournisseur. NoOptionAllowed: Ni la création ni la liaison sont autorisées pour ce fournisseur. Veuillez contacter votre administrateur. + LoginFailedSwitchLocal: | + Échec de la connexion au fournisseur d'identité externe. Retour à la connexion locale. + + Détails de l'erreur: {{.Details}} GrantRequired: Connexion impossible. L'utilisateur doit avoir au moins une subvention sur l'application. Veuillez contacter votre administrateur. ProjectRequired: Connexion impossible. L'organisation de l'utilisateur doit être accordée au projet. Veuillez contacter votre administrateur. IdentityProvider: diff --git a/internal/api/ui/login/static/i18n/hu.yaml b/internal/api/ui/login/static/i18n/hu.yaml index 80ed98945c..ef2a2acab4 100644 --- a/internal/api/ui/login/static/i18n/hu.yaml +++ b/internal/api/ui/login/static/i18n/hu.yaml @@ -465,6 +465,10 @@ Errors: CreationNotAllowed: Új felhasználó létrehozása nem engedélyezett ezen a szolgáltatón LinkingNotAllowed: A felhasználó összekapcsolása nem engedélyezett ezen a szolgáltatón NoOptionAllowed: Sem új felhasználó létrehozása, sem összekapcsolás nem engedélyezett ezen a szolgáltatón. Kérjük, lépj kapcsolatba az adminisztrátoroddal. + LoginFailedSwitchLocal: | + Az egyéni azonosító szolgáltatóhoz való bejelentkezés sikertelen volt. Visszatérés a helyi bejelentkezéshez. + + Hiba részletei: {{.Details}} GrantRequired: Bejelentkezés nem lehetséges. A felhasználónak legalább egy jogosultsággal kell rendelkeznie az alkalmazáson. Kérlek, lépj kapcsolatba az adminisztrátoroddal. ProjectRequired: Bejelentkezés nem lehetséges. A felhasználó szervezetének engedélyezve kell lennie a projektre. Kérlek, lépj kapcsolatba az adminisztrátoroddal. IdentityProvider: diff --git a/internal/api/ui/login/static/i18n/id.yaml b/internal/api/ui/login/static/i18n/id.yaml index 63deb41229..7fdd1bee1a 100644 --- a/internal/api/ui/login/static/i18n/id.yaml +++ b/internal/api/ui/login/static/i18n/id.yaml @@ -464,6 +464,10 @@ Errors: CreationNotAllowed: Pembuatan pengguna baru tidak diperbolehkan pada penyedia ini LinkingNotAllowed: Menautkan pengguna tidak diperbolehkan di penyedia ini NoOptionAllowed: 'Pembuatan tautan tidak diperbolehkan pada penyedia ini. ' + LoginFailedSwitchLocal: | + Gagal masuk ke Penyedia ID Eksternal. Kembali ke login lokal. + + Detail kesalahan: {{.Details}} GrantRequired: 'Masuk tidak dapat dilakukan. ' ProjectRequired: 'Masuk tidak dapat dilakukan. ' IdentityProvider: diff --git a/internal/api/ui/login/static/i18n/it.yaml b/internal/api/ui/login/static/i18n/it.yaml index 46e74d3b13..ca681b6e82 100644 --- a/internal/api/ui/login/static/i18n/it.yaml +++ b/internal/api/ui/login/static/i18n/it.yaml @@ -505,6 +505,10 @@ Errors: CreationNotAllowed: La creazione di un nuovo utente non è consentita su questo provider. LinkingNotAllowed: Il collegamento di un utente non è consentito su questo provider. NoOptionAllowed: Né la creazione né il collegamento sono consentiti per questo provider. Contattare l'amministratore. + LoginFailedSwitchLocal: | + Accesso al provider di identità esterno non riuscito. Ritorno all'accesso locale. + + Dettagli dell'errore: {{.Details}} GrantRequired: Accesso non possibile. L'utente deve avere almeno una sovvenzione sull'applicazione. Contatta il tuo amministratore. ProjectRequired: Accesso non possibile. L'organizzazione dell'utente deve essere concessa al progetto. Contatta il tuo amministratore. IdentityProvider: diff --git a/internal/api/ui/login/static/i18n/ja.yaml b/internal/api/ui/login/static/i18n/ja.yaml index 9ec99eb912..8d725785c6 100644 --- a/internal/api/ui/login/static/i18n/ja.yaml +++ b/internal/api/ui/login/static/i18n/ja.yaml @@ -469,6 +469,10 @@ Errors: CreationNotAllowed: このプロバイダーでは、新しいユーザーの作成は許可されていません LinkingNotAllowed: このプロバイダーでは、ユーザーのリンクが許可されていません NoOptionAllowed: このプロバイダーでは作成もリンクも許可されていません。 管理者にお問い合わせください。 + LoginFailedSwitchLocal: | + 外部IDプロバイダーへのログインに失敗しました。ローカルログインに戻ります。 + + エラーの詳細: {{.Details}} GrantRequired: ログインできません。このユーザーは、アプリケーションに少なくとも1つの権限を付与されていることが必要です。管理者にお問い合わせください。 ProjectRequired: ログインできません。ユーザーの組織がプロジェクトに権限を付与されている必要があります。管理者にお問い合わせください。 IdentityProvider: diff --git a/internal/api/ui/login/static/i18n/ko.yaml b/internal/api/ui/login/static/i18n/ko.yaml index e62cfcb8b5..bbe7a403a0 100644 --- a/internal/api/ui/login/static/i18n/ko.yaml +++ b/internal/api/ui/login/static/i18n/ko.yaml @@ -505,6 +505,10 @@ Errors: CreationNotAllowed: 이 제공자에서는 새 사용자 생성을 허용하지 않습니다 LinkingNotAllowed: 이 제공자에서는 사용자를 연결할 수 없습니다 NoOptionAllowed: 이 제공자에서는 생성과 연결이 모두 허용되지 않습니다. 관리자에게 문의하세요. + LoginFailedSwitchLocal: | + 외부 IDP에서 로그인에 실패했습니다. 로컬 로그인으로 돌아갑니다. + + 오류 세부 정보: {{.Details}} GrantRequired: 로그인 불가. 사용자는 애플리케이션에서 최소한 하나의 권한이 필요합니다. 관리자에게 문의하세요. ProjectRequired: 로그인 불가. 사용자의 조직이 프로젝트에 허가되어야 합니다. 관리자에게 문의하세요. IdentityProvider: diff --git a/internal/api/ui/login/static/i18n/mk.yaml b/internal/api/ui/login/static/i18n/mk.yaml index dbb988a0a6..2465c935b2 100644 --- a/internal/api/ui/login/static/i18n/mk.yaml +++ b/internal/api/ui/login/static/i18n/mk.yaml @@ -506,6 +506,10 @@ Errors: CreationNotAllowed: Креирањето на нов корисник не е дозволено на овој провајдер LinkingNotAllowed: Поврзувањето на корисник не е дозволено на овој провајдер NoOptionAllowed: NНиту создавање, ниту поврзување е дозволено за овој провајдер. Ве молиме контактирајте го вашиот администратор. + LoginFailedSwitchLocal: | + Најавата во надворешен провајдер на идентитет не успеа. Враќање на локална најава. + + Детали за грешката: {{.Details}} GrantRequired: Не е можно најавување. Корисникот мора да има барем едно овластување за апликацијата. Ве молиме контактирајте го вашиот администратор. ProjectRequired: Не е можно најавување. Организацијата на корисникот мора да биде доделена на проектот. Ве молиме контактирајте го вашиот администратор. IdentityProvider: diff --git a/internal/api/ui/login/static/i18n/nl.yaml b/internal/api/ui/login/static/i18n/nl.yaml index 3bbcee94b6..2bc1137154 100644 --- a/internal/api/ui/login/static/i18n/nl.yaml +++ b/internal/api/ui/login/static/i18n/nl.yaml @@ -505,6 +505,10 @@ Errors: CreationNotAllowed: Creatie van een nieuwe gebruiker is niet toegestaan op deze Provider LinkingNotAllowed: Koppeling van een gebruiker is niet toegestaan op deze Provider NoOptionAllowed: Noch aanmaak noch koppeling is toegestaan voor deze provider. Neem contact op met uw beheerder. + LoginFailedSwitchLocal: | + Aanmelding bij externe identiteitsprovider is mislukt. Terug naar lokale aanmelding. + + Foutdetails: {{.Details}} GrantRequired: Inloggen niet mogelijk. De gebruiker moet minimaal één grant hebben op de applicatie. Neem contact op met uw beheerder. ProjectRequired: Inloggen niet mogelijk. De organisatie van de gebruiker moet toegekend zijn aan het project. Neem contact op met uw beheerder. IdentityProvider: diff --git a/internal/api/ui/login/static/i18n/pl.yaml b/internal/api/ui/login/static/i18n/pl.yaml index 2c8b4fddf0..912af49a74 100644 --- a/internal/api/ui/login/static/i18n/pl.yaml +++ b/internal/api/ui/login/static/i18n/pl.yaml @@ -506,6 +506,10 @@ Errors: CreationNotAllowed: Tworzenie nowego użytkownika nie jest dozwolone w tym Providencie LinkingNotAllowed: Linkowanie użytkownika nie jest dozwolone na tym Providencie NoOptionAllowed: Ani tworzenie, ani łączenie nie jest dozwolone dla tego dostawcy. Skontaktuj się z administratorem. + LoginFailedSwitchLocal: | + Logowanie w zewnętrznym dostawcy tożsamości nie powiodło się. Powrót do logowania lokalnego. + + Szczegóły błędu: {{.Details}} GrantRequired: Logowanie nie jest możliwe. Użytkownik musi posiadać przynajmniej jedno uprawnienie w aplikacji. Skontaktuj się z administratorem. ProjectRequired: Logowanie nie jest możliwe. Organizacja użytkownika musi zostać udzielona projektowi. Skontaktuj się z administratorem. IdentityProvider: diff --git a/internal/api/ui/login/static/i18n/pt.yaml b/internal/api/ui/login/static/i18n/pt.yaml index f03f120ed8..5f18157e67 100644 --- a/internal/api/ui/login/static/i18n/pt.yaml +++ b/internal/api/ui/login/static/i18n/pt.yaml @@ -502,6 +502,10 @@ Errors: CreationNotAllowed: A criação de um novo usuário não é permitida neste provedor LinkingNotAllowed: A vinculação de um usuário não é permitida neste provedor NoOptionAllowed: Nem criação nem vinculação são permitidas neste fornecedor. Contate o seu administrador. + LoginFailedSwitchLocal: | + Falha no login no provedor de identidade externo. Retornando ao login local. + + Detalhes do erro: {{.Details}} GrantRequired: Login não é possível. O usuário precisa ter pelo menos uma permissão no aplicativo. Entre em contato com o administrador. ProjectRequired: Login não é possível. A organização do usuário precisa ser concedida ao projeto. Entre em contato com o administrador. IdentityProvider: diff --git a/internal/api/ui/login/static/i18n/ru.yaml b/internal/api/ui/login/static/i18n/ru.yaml index 221c20a2e9..8afd3a31b6 100644 --- a/internal/api/ui/login/static/i18n/ru.yaml +++ b/internal/api/ui/login/static/i18n/ru.yaml @@ -506,6 +506,10 @@ Errors: CreationNotAllowed: Создание нового пользователя для этого провайдера запрещено LinkingNotAllowed: Привязка к этому провайдеру запрещена NoOptionAllowed: Ни создание, ни привязка пользователя к этому провайдеру невозможны. Обратитесь к администратору. + LoginFailedSwitchLocal: | + Вход в внешний поставщик идентификации не удался. Возвращаемся к локальному входу. + + Подробности об ошибке: {{.Details}} GrantRequired: Вход невозможен. Пользователь должен иметь хотя бы один допуск к приложению. Обратитесь к администратору. ProjectRequired: Вход невозможен. Организация пользователя должна иметь доступ к проекту. Обратитесь к администратору. IdentityProvider: diff --git a/internal/api/ui/login/static/i18n/sv.yaml b/internal/api/ui/login/static/i18n/sv.yaml index 26fee23551..e6c1245503 100644 --- a/internal/api/ui/login/static/i18n/sv.yaml +++ b/internal/api/ui/login/static/i18n/sv.yaml @@ -505,6 +505,10 @@ Errors: CreationNotAllowed: Det är inte tillåtet att skapa nya konton från den här externa leverantören LinkingNotAllowed: Det är inte tillåtet att koppla ihop konton från den här externa leverantören NoOptionAllowed: Varken skapande eller länkande är tillåtet för denna leverantör. Kontakta administratören. + LoginFailedSwitchLocal: | + Inloggning vid extern identitetsprovider misslyckades. Återgår till lokal inloggning. + + Felaktighetsdetaljer: {{.Details}} GrantRequired: Det går inte att logga in just nu. Användarkontot har inte tillgång till någonting i tjänsten. Ta kontakt med systemansvarig. ProjectRequired: Det går inte att logga in just nu. Användarkontots organisation har inte tillgång till tjänsten. Ta kontakt med systemansvarig. IdentityProvider: diff --git a/internal/api/ui/login/static/i18n/zh.yaml b/internal/api/ui/login/static/i18n/zh.yaml index 79db3c020e..4fcb469831 100644 --- a/internal/api/ui/login/static/i18n/zh.yaml +++ b/internal/api/ui/login/static/i18n/zh.yaml @@ -505,6 +505,10 @@ Errors: CreationNotAllowed: 不允许在该供应商上创建新用户 LinkingNotAllowed: 在此提供者上不允许链接一个用户 NoOptionAllowed: 此提供商不允许创建或链接。请联系您的管理员。 + LoginFailedSwitchLocal: | + 外部身份提供商的登录失败。返回到本地登录。 + + 错误详情: {{.Details}} GrantRequired: 无法登录,用户需要在应用程序上拥有至少一项授权,请联系您的管理员。 ProjectRequired: 无法登录,用户的组织必须授予项目,请联系您的管理员。 IdentityProvider: diff --git a/internal/api/ui/login/static/resources/scripts/error_popup.js b/internal/api/ui/login/static/resources/scripts/error_popup.js new file mode 100644 index 0000000000..e817f81ac3 --- /dev/null +++ b/internal/api/ui/login/static/resources/scripts/error_popup.js @@ -0,0 +1,30 @@ +function removeOverlay(overlay) { + if (overlay.classList.contains("show")) { + overlay.classList.remove("show"); + document.removeEventListener("mousemove", onMouseMove); + document.removeEventListener("click", onClick); + } +} + +function onMouseMove() { + const overlay = document.getElementById("dialog_overlay"); + if (overlay) { + removeOverlay(overlay); + } +} + +function onClick() { + const overlay = document.getElementById("dialog_overlay"); + if (overlay) { + removeOverlay(overlay); + } +} + +window.addEventListener('DOMContentLoaded', () => { + const overlay = document.getElementById("dialog_overlay"); + if (overlay && overlay.classList.contains("show")) { + setTimeout(() => removeOverlay(overlay), 5000); + document.addEventListener("mousemove", onMouseMove); + document.addEventListener("click", onClick); + } +}); diff --git a/internal/api/ui/login/static/resources/themes/scss/main.scss b/internal/api/ui/login/static/resources/themes/scss/main.scss index 3b0ddc0da2..e9a97df2ca 100644 --- a/internal/api/ui/login/static/resources/themes/scss/main.scss +++ b/internal/api/ui/login/static/resources/themes/scss/main.scss @@ -20,3 +20,26 @@ body { .text-align-center { text-align: center; } + +.dialog_overlay { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background-color: black; + z-index: 1001; + -moz-opacity: 0.8; + opacity: .80; + filter: alpha(opacity=80); +} + +.dialog_overlay.show { + display: block; +} + +.dialog_content { + position: relative; + z-index: 1002; +} \ No newline at end of file diff --git a/internal/api/ui/login/static/resources/themes/scss/styles/error/error.scss b/internal/api/ui/login/static/resources/themes/scss/styles/error/error.scss index 39982e94ba..05131ec071 100644 --- a/internal/api/ui/login/static/resources/themes/scss/styles/error/error.scss +++ b/internal/api/ui/login/static/resources/themes/scss/styles/error/error.scss @@ -6,6 +6,10 @@ margin-right: .5rem; font-size: 1.5rem; } + + .lgn-error-message { + white-space: pre-line; + } } #wa-error { diff --git a/internal/api/ui/login/static/templates/error-message.html b/internal/api/ui/login/static/templates/error-message.html index 6c56caad1c..3de5d81bf4 100644 --- a/internal/api/ui/login/static/templates/error-message.html +++ b/internal/api/ui/login/static/templates/error-message.html @@ -1,10 +1,9 @@ {{ define "error-message" }} {{if .ErrMessage }} -
+
-

- {{ .ErrMessage }} -

+

{{ .ErrMessage }}

+
{{end}} -{{ end }} \ No newline at end of file +{{end}} \ No newline at end of file diff --git a/internal/api/ui/login/static/templates/password.html b/internal/api/ui/login/static/templates/password.html index c036e3c51b..98d94f3ef8 100644 --- a/internal/api/ui/login/static/templates/password.html +++ b/internal/api/ui/login/static/templates/password.html @@ -41,4 +41,5 @@ + diff --git a/internal/api/ui/login/static/templates/passwordless.html b/internal/api/ui/login/static/templates/passwordless.html index 6a95b54079..2dc6d544a0 100644 --- a/internal/api/ui/login/static/templates/passwordless.html +++ b/internal/api/ui/login/static/templates/passwordless.html @@ -40,5 +40,6 @@ + {{template "main-bottom" .}} diff --git a/internal/api/ui/login/username_change_handler.go b/internal/api/ui/login/username_change_handler.go index f11fe43a72..b932079dd0 100644 --- a/internal/api/ui/login/username_change_handler.go +++ b/internal/api/ui/login/username_change_handler.go @@ -16,12 +16,8 @@ type changeUsernameData struct { } func (l *Login) renderChangeUsername(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, err error) { - var errID, errMessage string - if err != nil { - errID, errMessage = l.getErrorMessage(r, err) - } translator := l.getTranslator(r.Context(), authReq) - data := l.getUserData(r, authReq, translator, "UsernameChange.Title", "UsernameChange.Description", errID, errMessage) + data := l.getUserData(r, authReq, translator, "UsernameChange.Title", "UsernameChange.Description", err) l.renderer.RenderTemplate(w, r, translator, l.renderer.Templates[tmplChangeUsername], data, nil) } @@ -41,8 +37,7 @@ func (l *Login) handleChangeUsername(w http.ResponseWriter, r *http.Request) { } func (l *Login) renderChangeUsernameDone(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest) { - var errType, errMessage string translator := l.getTranslator(r.Context(), authReq) - data := l.getUserData(r, authReq, translator, "UsernameChangeDone.Title", "UsernameChangeDone.Description", errType, errMessage) + data := l.getUserData(r, authReq, translator, "UsernameChangeDone.Title", "UsernameChangeDone.Description", nil) l.renderer.RenderTemplate(w, r, translator, l.renderer.Templates[tmplChangeUsernameDone], data, nil) } diff --git a/internal/auth/repository/auth_request.go b/internal/auth/repository/auth_request.go index d89eb35a8b..c16a757a01 100644 --- a/internal/auth/repository/auth_request.go +++ b/internal/auth/repository/auth_request.go @@ -41,4 +41,5 @@ type AuthRequestRepository interface { AutoRegisterExternalUser(ctx context.Context, user *domain.Human, externalIDP *domain.UserIDPLink, orgMemberRoles []string, authReqID, userAgentID, resourceOwner string, metadatas []*domain.Metadata, info *domain.BrowserInfo) error ResetLinkingUsers(ctx context.Context, authReqID, userAgentID string) error ResetSelectedIDP(ctx context.Context, authReqID, userAgentID string) error + RequestLocalAuth(ctx context.Context, authReqID, userAgentID string) error } diff --git a/internal/auth/repository/eventsourcing/eventstore/auth_request.go b/internal/auth/repository/eventsourcing/eventstore/auth_request.go index 813c5668f4..60486b66f9 100644 --- a/internal/auth/repository/eventsourcing/eventstore/auth_request.go +++ b/internal/auth/repository/eventsourcing/eventstore/auth_request.go @@ -563,6 +563,15 @@ func (repo *AuthRequestRepo) ResetSelectedIDP(ctx context.Context, authReqID, us return repo.AuthRequests.UpdateAuthRequest(ctx, request) } +func (repo *AuthRequestRepo) RequestLocalAuth(ctx context.Context, authReqID, userAgentID string) error { + request, err := repo.getAuthRequest(ctx, authReqID, userAgentID) + if err != nil { + return err + } + request.RequestLocalAuth = true + return repo.AuthRequests.UpdateAuthRequest(ctx, request) +} + func (repo *AuthRequestRepo) AutoRegisterExternalUser(ctx context.Context, registerUser *domain.Human, externalIDP *domain.UserIDPLink, orgMemberRoles []string, authReqID, userAgentID, resourceOwner string, metadatas []*domain.Metadata, info *domain.BrowserInfo) (err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -1059,7 +1068,7 @@ func (repo *AuthRequestRepo) nextSteps(ctx context.Context, request *domain.Auth request.PreferredLanguage = gu.Ptr(language.Make(user.HumanView.PreferredLanguage)) } - isInternalLogin := request.SelectedIDPConfigID == "" && userSession.SelectedIDPConfigID == "" + isInternalLogin := (request.SelectedIDPConfigID == "" && userSession.SelectedIDPConfigID == "") || request.RequestLocalAuth idps, err := checkExternalIDPsOfUser(ctx, repo.IDPUserLinksProvider, user.ID) if err != nil { return nil, err @@ -1067,7 +1076,9 @@ func (repo *AuthRequestRepo) nextSteps(ctx context.Context, request *domain.Auth noLocalAuth := request.LoginPolicy != nil && !request.LoginPolicy.AllowUsernamePassword allowedLinkedIDPs := checkForAllowedIDPs(request.AllowedExternalIDPs, idps.Links) - if (!isInternalLogin || len(allowedLinkedIDPs) > 0 || noLocalAuth) && len(request.LinkingUsers) == 0 { + if (!isInternalLogin || len(allowedLinkedIDPs) > 0 || noLocalAuth) && + len(request.LinkingUsers) == 0 && + !request.RequestLocalAuth { step, err := repo.idpChecked(request, allowedLinkedIDPs, userSession) if err != nil { return nil, err diff --git a/internal/auth/repository/eventsourcing/eventstore/auth_request_test.go b/internal/auth/repository/eventsourcing/eventstore/auth_request_test.go index 976ae8d8a9..7d71ddecd9 100644 --- a/internal/auth/repository/eventsourcing/eventstore/auth_request_test.go +++ b/internal/auth/repository/eventsourcing/eventstore/auth_request_test.go @@ -2263,6 +2263,86 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { []domain.NextStep{&domain.LinkUsersStep{}}, nil, }, + { + "local auth requested (passwordless and password set up), passwordless step", + fields{ + userSessionViewProvider: &mockViewUserSession{}, + userViewProvider: &mockViewUser{ + PasswordSet: true, + IsEmailVerified: true, + MFAMaxSetUp: int32(domain.MFALevelMultiFactor), + PasswordlessTokens: user_view_model.WebAuthNTokens{&user_view_model.WebAuthNView{ID: "id", State: int32(user_model.MFAStateReady)}}, + }, + userEventProvider: &mockEventUser{}, + orgViewProvider: &mockViewOrg{State: domain.OrgStateActive}, + lockoutPolicyProvider: &mockLockoutPolicy{ + policy: &query.LockoutPolicy{ + ShowFailures: true, + }, + }, + idpUserLinksProvider: &mockIDPUserLinks{ + idps: []*query.IDPUserLink{{IDPID: "IDPConfigID"}}, + }, + }, + args{ + &domain.AuthRequest{ + UserID: "UserID", + SelectedIDPConfigID: "IDPConfigID", + LoginPolicy: &domain.LoginPolicy{ + PasswordlessType: domain.PasswordlessTypeAllowed, + }, + AllowedExternalIDPs: []*domain.IDPProvider{ + { + IDPConfigID: "IDPConfigID", + }, + }, + RequestLocalAuth: true, + }, false}, + []domain.NextStep{ + &domain.PasswordlessStep{ + PasswordSet: true, + }, + }, + nil, + }, + { + "local auth requested (password set up), password step", + fields{ + userSessionViewProvider: &mockViewUserSession{}, + userViewProvider: &mockViewUser{ + PasswordSet: true, + IsEmailVerified: true, + }, + userEventProvider: &mockEventUser{}, + orgViewProvider: &mockViewOrg{State: domain.OrgStateActive}, + lockoutPolicyProvider: &mockLockoutPolicy{ + policy: &query.LockoutPolicy{ + ShowFailures: true, + }, + }, + idpUserLinksProvider: &mockIDPUserLinks{ + idps: []*query.IDPUserLink{{IDPID: "IDPConfigID"}}, + }, + }, + args{ + &domain.AuthRequest{ + UserID: "UserID", + SelectedIDPConfigID: "IDPConfigID", + LoginPolicy: &domain.LoginPolicy{ + PasswordlessType: domain.PasswordlessTypeAllowed, + }, + AllowedExternalIDPs: []*domain.IDPProvider{ + { + IDPConfigID: "IDPConfigID", + }, + }, + RequestLocalAuth: true, + }, false}, + []domain.NextStep{ + &domain.PasswordStep{}, + }, + nil, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/domain/auth_request.go b/internal/domain/auth_request.go index 01b6ae25da..85ec340f67 100644 --- a/internal/domain/auth_request.go +++ b/internal/domain/auth_request.go @@ -60,6 +60,7 @@ type AuthRequest struct { DefaultTranslations []*CustomText OrgTranslations []*CustomText SAMLRequestID string + RequestLocalAuth bool // orgID the policies were last loaded with policyOrgID string // SessionID is set to the computed sessionID of the login session table From 75f0ad42e61d3ae7712c95e78eeb72d1442588d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabienne=20B=C3=BChler?= Date: Wed, 15 Jan 2025 14:29:13 +0100 Subject: [PATCH 22/30] docs: Login v2 docs (#9159) # Which Problems Are Solved As we are going into the Beta testing phase of our new typescript login (login V2), we need to have a documentation about the capabilities, how to test, and what the current limitations are. # How the Problems Are Solved Added new section for the Login V2 --------- Co-authored-by: Max Peintner Co-authored-by: Max Peintner --- .../integrate/login-ui/typescript-repo.mdx | 2 + .../guides/integrate/login/hosted-login.mdx | 207 ++++++++++++++++++ .../guides/integrate/login/login-users.mdx | 142 ++---------- docs/sidebars.js | 25 +++ .../integrate/login/login-v2-app-config.png | Bin 0 -> 373729 bytes docs/vercel.json | 15 +- 6 files changed, 265 insertions(+), 126 deletions(-) create mode 100644 docs/docs/guides/integrate/login/hosted-login.mdx create mode 100644 docs/static/img/guides/integrate/login/login-v2-app-config.png diff --git a/docs/docs/guides/integrate/login-ui/typescript-repo.mdx b/docs/docs/guides/integrate/login-ui/typescript-repo.mdx index d1a3f1d877..d5fd6d9e4d 100644 --- a/docs/docs/guides/integrate/login-ui/typescript-repo.mdx +++ b/docs/docs/guides/integrate/login-ui/typescript-repo.mdx @@ -146,4 +146,6 @@ Then create a personal access token (PAT), copy and set it as `ZITADEL_SERVICE_U Finally set your instance url as `ZITADEL_API_URL`. Make sure to set it without trailing slash. Also ensure your login domain is registered on your instance by adding it as a [trusted domain](/docs/apis/resources/admin/admin-service-add-instance-trusted-domain). +If you want to enforce users to have their email verified, you can set the optional `EMAIL_VERIFICATION` variable to `true` in your environment and your users will be enforced to verify their email address before they can log in. + ![Deploy to Vercel](/img/deploy-to-vercel.png) diff --git a/docs/docs/guides/integrate/login/hosted-login.mdx b/docs/docs/guides/integrate/login/hosted-login.mdx new file mode 100644 index 0000000000..fcb7729314 --- /dev/null +++ b/docs/docs/guides/integrate/login/hosted-login.mdx @@ -0,0 +1,207 @@ +--- +title: Login users into your application with a hosted login UI +sidebar_label: Hosted Login UI +--- + +ZITADEL provides a hosted single-sign-on page to securely sign-in users to your applications. +ZITADEL's hosted login page serves as a centralized authentication interface provided for applications that integrate ZITADEL. +As a developer, understanding the hosted login page is essential for seamlessly integrating authentication into your application. + +## Centralized authentication endpoint + +ZITADEL's hosted login page acts as a centralized authentication endpoint where users are redirected to authenticate themselves. +When users attempt to access a protected resource within your application, you can redirect them to the hosted login page to authenticate using their login methods and credentials or through Single-sign-on (SSO). +After successful authentication, the user will be redirected back to the originating application. + +## Security and compliance + +ZITADEL's hosted login page prioritizes security and compliance with industry standards and regulations. +It employs best practices for securing authentication processes, such as encryption, token-based authentication, and adherence to protocols like OAuth 2.0, [OpenID Connect](/docs/guides/integrate/login/oidc), and [SAML](/docs/guides/integrate/login/). + +We make sure to harden the login UI and minimize the attack surface. +One of the measures we apply is setting the necessary security heads thus minimizing the risk of common vulnerabilities in login pages, such as XSS vulnerabilities. +Put your current login to the test and compare the results with our hosted login page. +Tools like [Mozilla's Observatory](https://observatory.mozilla.org/) can give you a good first impression about the security posture. + +## Developer-friendly integration + +Integrating the hosted login page into your application is straightforward, thanks to ZITADEL's developer-friendly documentation, SDKs, and APIs. Developers can easily implement authentication flows, handle authentication callbacks, and customize the user experience to seamlessly integrate authentication with their application's workflow. + +Overall, ZITADEL's hosted login page simplifies the authentication process for developers by providing a secure, customizable, and developer-friendly authentication interface. By leveraging this centralized authentication endpoint, developers can enhance their application's security, user experience, and compliance with industry standards and regulations. + +## Key features of the hosted login + +### Flexible usernames + +Different login name formats can be used on ZITADEL's hosted login page to select a user. +Login methods can be a user's username, containing the username and an [organization domain](/docs/guides/manage/console/organizations#domain-verification-and-primary-domain), their email addresses, or their phone numbers. +By default, all of these login methods are allowed and can be adjusted by [Managers](/docs/concepts/structure/managers) to meet their requirements. + +### Support for multiple authentication methods + +The hosted login page supports various authentication methods, including traditional username/password authentication, social login options, multi-factor authentication (MFA), and passwordless authentication methods like [passkeys](/docs/concepts/features/passkeys.md). +The second factor (2FA) and multi-factor authentication methods (MFA) available in ZITADEL include OTP via an authenticator app, TOTP via SMS, OTP via email, and U2F. + +Developers can configure the authentication methods offered on the login page based on their application's security and usability requirements. + +### Enterprise single-sign-on + +![Screenshot of ZITADEL console showing different identity provider templates](/img/guides/integrate/login/login-external-idp-templates.png) + +With the hosted login page from ZITADEL developers will get the best support for multi-tenancy single-sign-on with third-party identity providers. +ZITADEL acts as an [identity broker](/docs/concepts/features/identity-brokering) between your applications and different external identity providers, reducing the implementation effort for developers. +External Identity providers can be configured for the whole instance or for each organization that represents a group of users such as a B2B customer or organizational unit. + +ZITADEL offers various [identity provider templates](/docs/guides/integrate/identity-providers/introduction) to integrate providers such as [Okta](/docs/guides/integrate/identity-providers/okta-oidc), [Entra ID](/docs/guides/integrate/identity-providers/azure-ad-oidc) or on-premise [LDAP](/docs/guides/integrate/identity-providers/ldap). + +### Multi-tenancy authentication + +ZITADEL simplifies multi-tenancy authentication by securely managing authentication for multiple tenants, called [Organizations](/docs/concepts/structure/organizations), within a single [instance](/docs/concepts/structure/instance). + +Key features include: + +1. **Secure Tenant Isolation**: Ensures robust security measures to prevent unauthorized access between tenants, maintaining data privacy and compliance. [Managers](/docs/concepts/structure/managers) for an organization have only access to data and configuration within their Organization. +2. **Custom Authentication Configurations**: Allows tailored [authentication settings](/docs/guides/manage/console/default-settings#login-behavior-and-access), [branding](/docs/guides/manage/customize/branding), and policies for each tenant. +3. **Centralized Management**: Provides [centralized administration](/docs/guides/manage/console/managers) for efficient management across all tenants. +4. **Scalability and Flexibility**: Scales seamlessly to accommodate growing organizations of all sizes. +5. **Domain Discovery**: Starting on a central login page, route users to their tenant based on their email address or other user attributes. Authentication settings will be applied automatically based on the organization's policies, this includes routing users seamlessly to third party identity providers like [Entra ID](/docs/guides/integrate/identity-providers/azure-ad-oidc). + +### Customization options + +While the hosted login page provides a default authentication interface out-of-the-box, ZITADEL offers [customization options](/docs/guides/manage/customize/branding) to tailor the login page to match your application's branding and user experience requirements. +Developers can customize elements such as logos, colors, and messaging to ensure a seamless integration with their application's user interface. + +:::info Customization and Branding +The login page can be changed by customizing different branding aspects and you can define a custom domain for the login (eg, login.acme.com). + +By default, the displayed branding is defined [based on the user's domain](/docs/guides/solution-scenarios/domain-discovery). In case you want to show the branding of a specific organization by default, you need to either pass a primary domain scope (`urn:zitadel:iam:org:domain:primary:{domainname}`) with the authorization request, or define the behavior on your Project's settings. +::: + +### Fast account switching + +The hosted login page remembers users who have previously authenticated. +In case a user has used multiple accounts, for example, a private account and a work account, to authenticate, then all accounts will be shown on the Account Picker. +Users can still login with a different user that is not on the list. +This allows users to quickly switch between users and provide a better user experience. + +:::info +This behavior can be changed with the authorization request. Please refer to our [guide](/guides/integrate/login/oidc/login-users). +::: + +### Self-service for users + +ZITADEL's hosted login page offers [many self-service flows](/docs/concepts/features/selfservice) that allow users to set up authentication methods or recover their login information. +Developers use the self-service functionalities to reduce manual tasks and improve user experience. +Key features include: + +### Password reset + +Unauthenticated users can request a password reset after providing the loginname during the login flow. + +- User selects reset password +- An email will be sent to the verified email address +- User opens a link and has to provide a new password + +#### Prompt users to set up multifactor authentication + +Users are automatically prompted to provide a second factor, when + +- Instance or organization [login policy](/concepts/structure/policies#login-policy) is set +- Requested by the client +- A multi-factor is set up for the user + +When a multi-factor is required, but not set up, then the user is requested to set up an additional factor. + +:::info Disabling multifactor prompt +You can disable the prompt, in case multifactor authentication is not enforced by setting the [**Multifactor Init Lifetime**](/docs/guides/manage/console/default-settings#login-lifetimes) to 0. +::: + +#### Enroll passkeys + +Users can select a button to initiate passwordless login or use a fall-back method (ie. login with username/password), if available. + +The passwordless with [passkeys](/docs/concepts/features/passkeys.md) login flow follows the FIDO2 / WebAuthN standard. +With the introduction of passkeys the gesture can be provided on ANY of the user's devices. +This is not strictly the device where the login flow is being executed (e.g., on a mobile device). +The user experience depends mainly on the operating system and browser. + +## Hosted Login Version 2 (Beta) + +We have worked on a new, self-hostable implementation of our hosted login built with Next.js and leveraging our [Session API](/docs/guides/integrate/login/login-users#zitadels-session-api). +This solution empowers you to easily fork and customize the login experience to perfectly match your brand and needs. + +In this initial release, the new login is available for self-hosting only. We'll be progressively replacing the built-in login with this improved version, built with [TypeScript](https://github.com/zitadel/typescript). + +### Current State + +Our primary goal for the TypeScript login system is to replace the existing login functionality within Zitadel Core, which is shipped with Zitadel automatically. This will allow us to leverage the benefits of the new system, including its modular architecture and enhanced security features. + +To achieve this, we are actively working on implementing the core features currently available in Zitadel Core, such as: + +- **Authentication Methods:** + - Username and Password + - Passkeys + - Multi-Factor Authentication (MFA) + - External Identity Providers (OIDC, SAML, etc.) +- **OpenID Connect (OIDC) Compliance:** Adherence to the OIDC standard for seamless integration with various identity providers. +- **Customization**: + - Branding options to match your organization's identity. + - Flexible configuration settings to tailor the login experience. + +The full feature list can be found [here](https://github.com/zitadel/typescript?tab=readme-ov-file#features-list). + +As we continue to develop the TypeScript login system, we will provide regular updates on its progress and new capabilities. + +### Limitations + +For the first implementation we have excluded the following features: + +- SAML (SP & OP) +- Generic JWT IDP +- LDAP IDP +- Device Authorization Grants +- Timebased features + - Lockout Settings + - Password Expiry Settings + - Login Settings - Multifactor init prompt + - Force MFA on external authenticated users +- Passkey/U2F Setup + - As passkey and u2f is bound to a domain, it is important to notice, that setting up the authentication possibility in the ZITADEL management console (Self-service), will not work if the login runs on a different domain +- Custom Login Texts + +### Beta Testing + +The TypeScript login system is currently in beta testing. Your feedback is invaluable in helping us refine and improve this new solution. +At your convenience please open any issues faced on our Typescript Login GitHub repository to report bugs or suggest enhancements while more general feedback can be shared directly to fabienne@zitadel.com. +Your contributions will play a crucial role in shaping the future of our login system. Thank you for your support! + +#### Step-by-step Guide + +The simplest way to deploy the new login for yourself is by using the [“Deploy” button in our repository](https://github.com/zitadel/typescript?tab=readme-ov-file#deploy-to-vercel) to deploy the login directly to your Vercel. + +1. [Create a service user](https://zitadel.com/docs/guides/integrate/service-users/personal-access-token#create-a-service-user-with-a-pat) (ZITADEL_SERVICE_USER_ID) with a PAT in your instance +2. Give the user IAM_LOGIN_CLIENT Permissions in the default settings (YOUR_DOMAIN/ui/console/instance?id=organizations) + Note: [Zitadel Manager Guide](https://zitadel.com/docs/guides/manage/console/managers) +3. Deploy login to Vercel: You can do so, be directly clicking the [“Deploy” button](https://github.com/zitadel/typescript?tab=readme-ov-file#deploy-to-vercel) at the bottom of the readme in our [repository](https://github.com/zitadel/typescript) +4. If you have used the deploy button in the steps before, you will automatically be asked for this step. Enter the environment variables in Vercel + - ZITADEL_SERVICE_USER_ID + - PAT + - ZITADEL_API_URL (Example: https://my-domain.zitadel.cloud, no trailing slash) +5. Add the domain where your login UI is hosted to the [trusted domains](https://zitadel.com/docs/apis/resources/admin/admin-service-add-instance-trusted-domain) in Zitadel. (Example: my-new-zitadel-login.vercel.app) +6. Use the new login in your application. You have three different options on how to achieve this + 1. Enable the new login on your application configuration and add the URL of your login UI, with that settings Zitadel will automatically redirect you to the new login if you call the old one. + ![Login V2 Application Configuration](/img/guides/integrate/login/login-v2-app-config.png) + 2. Enable the [loginV2 feature](https://zitadel.com/docs/apis/resources/feature_service_v2/feature-service-set-instance-features) on the instance and add the URL of your login. If you enable this feature, the login will be used for every application configured in your Zitadel instance. (Example: https://my-new-zitadel-login.vercel.app) + 3. Change the issuer in the code of your application to the new domain of your login +7. Enforce users to have their email verified. By setting `EMAIL_VERIFICATION` to `true` in your environment variables, your users will be enforced to verify their email address before they can log in. + +### Important Notes + +As this feature is currently in Beta, please be aware of some potential workarounds and important considerations before implementation. + +- **Create Users:** The new typescript login is built with the session and the user V2 API, the users V2 API does have some differences to the v1 API, so make sure you create users through the new API. +- **External IDPs:** If you want to use external identity provider login, such as Login with Google or Apple. You can follow our existing setup guides, just make sure to use the following redirect url: $YOUR-DOMAIN/idps/callback +- **Passkey/U2F:** Those authentication methods are bound to a domain. As your new login runs on a different domain than the previous login, existing passwordless authentication and u2f (fingerprint, face id, etc.) can’t be used. Also when they are managed through the management console of ZITADEL, they are added on a different domain. +
+ *Note: If you run the login on a subdomain of your current instance, this problem + can be avoided. E.g myinstance.zitadel.cloud and login.myinstance.zitadel.cloud* diff --git a/docs/docs/guides/integrate/login/login-users.mdx b/docs/docs/guides/integrate/login/login-users.mdx index 82c9dbe5a4..e80e70c5b7 100644 --- a/docs/docs/guides/integrate/login/login-users.mdx +++ b/docs/docs/guides/integrate/login/login-users.mdx @@ -1,6 +1,6 @@ --- -title: Login users into your application with a hosted or custom login UI -sidebar_label: Hosted vs. Custom Login UI +title: Log users into your application with different authentication options +sidebar_label: Authentication Options --- ZITADEL is a comprehensive identity and access management platform designed to streamline user authentication, authorization, and management processes for your application. It offers a range of features, including single sign-on (SSO), multi-factor authentication (MFA), and centralized user management. @@ -25,6 +25,8 @@ The identity provider is not part of the original application, but a standalone The user will authenticate using their credentials. After successful authentication, the user will be redirected back to the original application. +If you want to read more about authenticating with OIDC, head over to our comprehensive [OpenID Connect Guide](/docs/integrate/login/oidc). + ### Authenticate users with SAML SAML (Security Assertion Markup Language) is a widely adopted standard for exchanging authentication and authorization data between identity providers and service providers. @@ -52,13 +54,14 @@ Note that SAML might not be suitable for mobile applications. In case you want to integrate a mobile application, use OpenID Connect or our Session API. There are more [differences between SAML and OIDC](https://zitadel.com/blog/saml-vs-oidc) that you might want to consider. +If you want to read more about authenticating with SAML, head over to our comprehensive [SAML Guide](/docs/integrate/login/saml). -### ZITADEL's Session API +## ZITADEL's Session API ZITADEL's [Session API](/docs/apis/resources/session_service_v2) provides developers with a straightforward method to manage user sessions within their applications. The Session API is not an industry-standard and can be used instead of OpenID Connect or SAML to authenticate users by [building your own custom login user interface](/docs/guides/integrate/login-ui). -#### Tokens in the Session API +### Tokens in the Session API The session API will return a session token that can be used to authenticate users from your application. This token should not be confused with am access or id tokens in opaque or JWT form that is issued during OpenID connect flows. @@ -67,7 +70,7 @@ This token should not be confused with am access or id tokens in opaque or JWT f Token exchange between Session API and OIDC / SAML tokens is not possible at this moment. ::: -#### Key features of the Session API +### Key features of the Session API These are some key features of the API: @@ -85,127 +88,16 @@ Overall, ZITADEL's Session API simplifies session management within your applica ## Use the Hosted Login to sign-in users -ZITADEL provides a hosted single-sign-on page to securely sign-in users to your applications. -ZITADEL's hosted login page serves as a centralized authentication interface provided for applications that integrate ZITADEL. -As a developer, understanding the hosted login page is essential for seamlessly integrating authentication into your application. +ZITADEL provides a hosted single-sign-on page for secure user authentication within your applications. +This centralized authentication interface simplifies application integration by offering a ready-to-use login experience. +For a comprehensive understanding of the hosted login page and its capabilities, please refer to our [dedicated guide](/docs/guides/integrate/login/hosted-login) -### Centralized authentication endpoint - -ZITADEL's hosted login page acts as a centralized authentication endpoint where users are redirected to authenticate themselves. -When users attempt to access a protected resource within your application, you can redirect them to the hosted login page to authenticate using their login methods and credentials or through Single-sign-on (SSO). -After successful authentication, the user will be redirected back to the originating application. - -### Security and compliance - -ZITADEL's hosted login page prioritizes security and compliance with industry standards and regulations. -It employs best practices for securing authentication processes, such as encryption, token-based authentication, and adherence to protocols like OAuth 2.0, [OpenID Connect](/docs/guides/integrate/login/oidc), and [SAML](/docs/guides/integrate/login/). - -We make sure to harden the login UI and minimize the attack surface. -One of the measures we apply is setting the necessary security heads thus minimizing the risk of common vulnerabilities in login pages, such as XSS vulnerabilities. -Put your current login to the test and compare the results with our hosted login page. -Tools like [Mozilla's Observatory](https://observatory.mozilla.org/) can give you a good first impression about the security posture. - -### Developer-friendly integration - -Integrating the hosted login page into your application is straightforward, thanks to ZITADEL's developer-friendly documentation, SDKs, and APIs. Developers can easily implement authentication flows, handle authentication callbacks, and customize the user experience to seamlessly integrate authentication with their application's workflow. - -Overall, ZITADEL's hosted login page simplifies the authentication process for developers by providing a secure, customizable, and developer-friendly authentication interface. By leveraging this centralized authentication endpoint, developers can enhance their application's security, user experience, and compliance with industry standards and regulations. - -## Key features of the hosted login - -### Flexible usernames - -Different login name formats can be used on ZITADEL's hosted login page to select a user. -Login methods can be a user's username, containing the username and an [organization domain](/docs/guides/manage/console/organizations#domain-verification-and-primary-domain), their email addresses, or their phone numbers. -By default, all of these login methods are allowed and can be adjusted by [Managers](/docs/concepts/structure/managers) to meet their requirements. - -### Support for multiple authentication methods - -The hosted login page supports various authentication methods, including traditional username/password authentication, social login options, multi-factor authentication (MFA), and passwordless authentication methods like [passkeys](/docs/concepts/features/passkeys.md). -The second factor (2FA) and multi-factor authentication methods (MFA) available in ZITADEL include OTP via an authenticator app, TOTP via SMS, OTP via email, and U2F. - -Developers can configure the authentication methods offered on the login page based on their application's security and usability requirements. - -### Enterprise single-sign-on - -![Screenshot of ZITADEL console showing different identity provider templates](/img/guides/integrate/login/login-external-idp-templates.png) - -With the hosted login page from ZITADEL developers will get the best support for multi-tenancy single-sign-on with third-party identity providers. -ZITADEL acts as an [identity broker](/docs/concepts/features/identity-brokering) between your applications and different external identity providers, reducing the implementation effort for developers. -External Identity providers can be configured for the whole instance or for each organization that represents a group of users such as a B2B customer or organizational unit. - -ZITADEL offers various [identity provider templates](/docs/guides/integrate/identity-providers/introduction) to integrate providers such as [Okta](/docs/guides/integrate/identity-providers/okta-oidc), [Entra ID](/docs/guides/integrate/identity-providers/azure-ad-oidc) or on-premise [LDAP](/docs/guides/integrate/identity-providers/ldap). - -### Multi-tenancy authentication - -ZITADEL simplifies multi-tenancy authentication by securely managing authentication for multiple tenants, called [Organizations](/docs/concepts/structure/organizations), within a single [instance](/docs/concepts/structure/instance). - -Key features include: - -1. **Secure Tenant Isolation**: Ensures robust security measures to prevent unauthorized access between tenants, maintaining data privacy and compliance. [Managers](/docs/concepts/structure/managers) for an organization have only access to data and configuration within their Organization. -2. **Custom Authentication Configurations**: Allows tailored [authentication settings](/docs/guides/manage/console/default-settings#login-behavior-and-access), [branding](/docs/guides/manage/customize/branding), and policies for each tenant. -3. **Centralized Management**: Provides [centralized administration](/docs/guides/manage/console/managers) for efficient management across all tenants. -4. **Scalability and Flexibility**: Scales seamlessly to accommodate growing organizations of all sizes. -5. **Domain Discovery**: Starting on a central login page, route users to their tenant based on their email address or other user attributes. Authentication settings will be applied automatically based on the organization's policies, this includes routing users seamlessly to third party identity providers like [Entra ID](/docs/guides/integrate/identity-providers/azure-ad-oidc). - -### Customization options - -While the hosted login page provides a default authentication interface out-of-the-box, ZITADEL offers [customization options](/docs/guides/manage/customize/branding) to tailor the login page to match your application's branding and user experience requirements. -Developers can customize elements such as logos, colors, and messaging to ensure a seamless integration with their application's user interface. - -:::info Customization and Branding -The login page can be changed by customizing different branding aspects and you can define a custom domain for the login (eg, login.acme.com). - -By default, the displayed branding is defined [based on the user's domain](/docs/guides/solution-scenarios/domain-discovery). In case you want to show the branding of a specific organization by default, you need to either pass a primary domain scope (`urn:zitadel:iam:org:domain:primary:{domainname}`) with the authorization request, or define the behavior on your Project's settings. -::: - -### Fast account switching - -The hosted login page remembers users who have previously authenticated. -In case a user has used multiple accounts, for example, a private account and a work account, to authenticate, then all accounts will be shown on the Account Picker. -Users can still login with a different user that is not on the list. -This allows users to quickly switch between users and provide a better user experience. - -:::info -This behavior can be changed with the authorization request. Please refer to our [guide](/guides/integrate/login/oidc/login-users). -::: - -### Self-service for users - -ZITADEL's hosted login page offers [many self-service flows](/docs/concepts/features/selfservice) that allow users to set up authentication methods or recover their login information. -Developers use the self-service functionalities to reduce manual tasks and improve user experience. -Key features include: - -### Password reset - -Unauthenticated users can request a password reset after providing the loginname during the login flow. - -- User selects reset password -- An email will be sent to the verified email address -- User opens a link and has to provide a new password - -#### Prompt users to set up multifactor authentication - -Users are automatically prompted to provide a second factor, when - -- Instance or organization [login policy](/concepts/structure/policies#login-policy) is set -- Requested by the client -- A multi-factor is set up for the user - -When a multi-factor is required, but not set up, then the user is requested to set up an additional factor. - -:::info Disabling multifactor prompt -You can disable the prompt, in case multifactor authentication is not enforced by setting the [**Multifactor Init Lifetime**](/docs/guides/manage/console/default-settings#login-lifetimes) to 0. -::: - -#### Enroll passkeys - -Users can select a button to initiate passwordless login or use a fall-back method (ie. login with username/password), if available. - -The passwordless with [passkeys](/docs/concepts/features/passkeys.md) login flow follows the FIDO2 / WebAuthN standard. -With the introduction of passkeys the gesture can be provided on ANY of the user's devices. -This is not strictly the device where the login flow is being executed (e.g., on a mobile device). -The user experience depends mainly on the operating system and browser. +The hosted login is particularly well-suited for scenarios where: +- **Minimal branding is required:** If your primary focus is on functionality over a highly customized look and feel. +- **Standard authentication flows suffice:** Your application doesn't necessitate complex or unique authentication processes. +- **OIDC or SAML are suitable:** Your application integrates seamlessly with industry-standard protocols. +- **Time-to-market is critical:** You need a rapid and efficient authentication solution to accelerate your development timeline. +- **Embedding the login UI is unnecessary:** You prefer a separate, hosted login page for user authentication. ## Build a custom Login UI to authenticate users diff --git a/docs/sidebars.js b/docs/sidebars.js index 05a2c42342..3b379d57ee 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -206,6 +206,31 @@ module.exports = { }, items: [ "guides/integrate/login/login-users", + { + type: "link", + href: "/docs/guides/integrate/login/login-users#zitadels-session-api", + label: "Session API" + }, + { + type: "category", + label: "Hosted Login", + link: { + type: "doc", + id: "guides/integrate/login/hosted-login" + }, + items: [ + { + type: "link", + href: "/docs/guides/integrate/login/hosted-login#hosted-login-version-2-beta", + label: "Login V2 [Beta]" + }, + ] + }, + { + type: "link", + href: "/docs/guides/integrate/login/login-users#build-a-custom-login-ui-to-authenticate-users", + label: "Custom Login UI", + }, { type: "category", label: "OpenID Connect", diff --git a/docs/static/img/guides/integrate/login/login-v2-app-config.png b/docs/static/img/guides/integrate/login/login-v2-app-config.png new file mode 100644 index 0000000000000000000000000000000000000000..36ddedcbf1dbf1babaec390b6b76f94bf26500bd GIT binary patch literal 373729 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 Date: Wed, 15 Jan 2025 15:55:48 +0100 Subject: [PATCH 23/30] perf(eventstore): fast push on crdb (#9186) # Which Problems Are Solved The performance of the initial push function can further be increased # How the Problems Are Solved `eventstore.push`- and `eventstore.commands_to_events`-functions were rewritten # Additional Changes none # Additional Context same optimizations as for postgres: https://github.com/zitadel/zitadel/pull/9092 --- cmd/setup/40.go | 2 +- cmd/setup/40/cockroach/40_init_push_func.sql | 210 +++++++++++-------- e2e/config/localhost/docker-compose.yaml | 2 +- 3 files changed, 128 insertions(+), 86 deletions(-) diff --git a/cmd/setup/40.go b/cmd/setup/40.go index 0a3a116d21..39191a9b8d 100644 --- a/cmd/setup/40.go +++ b/cmd/setup/40.go @@ -48,5 +48,5 @@ func (mig *InitPushFunc) Execute(ctx context.Context, _ eventstore.Event) (err e } func (mig *InitPushFunc) String() string { - return "40_init_push_func_v2" + return "40_init_push_func_v3" } diff --git a/cmd/setup/40/cockroach/40_init_push_func.sql b/cmd/setup/40/cockroach/40_init_push_func.sql index c2e2e92b07..802dc759c9 100644 --- a/cmd/setup/40/cockroach/40_init_push_func.sql +++ b/cmd/setup/40/cockroach/40_init_push_func.sql @@ -10,8 +10,132 @@ CREATE TYPE IF NOT EXISTS eventstore.command AS ( , owner TEXT ); +CREATE OR REPLACE FUNCTION eventstore.latest_aggregate_state( + instance_id TEXT + , aggregate_type TEXT + , aggregate_id TEXT + + , sequence OUT BIGINT + , owner OUT TEXT +) + LANGUAGE 'plpgsql' +AS $$ + BEGIN + SELECT + COALESCE(e.sequence, 0) AS sequence + , e.owner + INTO + sequence + , owner + FROM + eventstore.events2 e + WHERE + e.instance_id = $1 + AND e.aggregate_type = $2 + AND e.aggregate_id = $3 + ORDER BY + e.sequence DESC + LIMIT 1; + + RETURN; + END; +$$; + +CREATE OR REPLACE FUNCTION eventstore.commands_to_events2(commands eventstore.command[]) + RETURNS eventstore.events2[] + LANGUAGE 'plpgsql' +AS $$ +DECLARE + current_sequence BIGINT; + current_owner TEXT; + + instance_id TEXT; + aggregate_type TEXT; + aggregate_id TEXT; + + _events eventstore.events2[]; + + _aggregates CURSOR FOR + select + DISTINCT ("c").instance_id + , ("c").aggregate_type + , ("c").aggregate_id + FROM + UNNEST(commands) AS c; +BEGIN + OPEN _aggregates; + LOOP + FETCH NEXT IN _aggregates INTO instance_id, aggregate_type, aggregate_id; + -- crdb does not support EXIT WHEN NOT FOUND + EXIT WHEN instance_id IS NULL; + + SELECT + * + INTO + current_sequence + , current_owner + FROM eventstore.latest_aggregate_state( + instance_id + , aggregate_type + , aggregate_id + ); + + -- RETURN QUERY is not supported by crdb: https://github.com/cockroachdb/cockroach/issues/105240 + SELECT + ARRAY_CAT(_events, ARRAY_AGG(e)) + INTO + _events + FROM ( + SELECT + ("c").instance_id + , ("c").aggregate_type + , ("c").aggregate_id + , ("c").command_type -- AS event_type + , COALESCE(current_sequence, 0) + ROW_NUMBER() OVER () -- AS sequence + , ("c").revision + , NOW() -- AS created_at + , ("c").payload + , ("c").creator + , COALESCE(current_owner, ("c").owner) -- AS owner + , EXTRACT(EPOCH FROM NOW()) -- AS position + , ordinality::INT -- AS in_tx_order + FROM + UNNEST(commands) WITH ORDINALITY AS c + WHERE + ("c").instance_id = instance_id + AND ("c").aggregate_type = aggregate_type + AND ("c").aggregate_id = aggregate_id + ) AS e; + END LOOP; + CLOSE _aggregates; + RETURN _events; +END; +$$; + +CREATE OR REPLACE FUNCTION eventstore.push(commands eventstore.command[]) RETURNS SETOF eventstore.events2 AS $$ + INSERT INTO eventstore.events2 + SELECT + ("e").instance_id + , ("e").aggregate_type + , ("e").aggregate_id + , ("e").event_type + , ("e").sequence + , ("e").revision + , ("e").created_at + , ("e").payload + , ("e").creator + , ("e").owner + , ("e")."position" + , ("e").in_tx_order + FROM + UNNEST(eventstore.commands_to_events2(commands)) e + ORDER BY + in_tx_order + RETURNING * +$$ LANGUAGE SQL; + /* -select * from eventstore.commands_to_events( +select (c).* from UNNEST(eventstore.commands_to_events2( ARRAY[ ROW('', 'system', 'SYSTEM', 'ct1', 1, '{"key": "value"}', 'c1', 'SYSTEM') , ROW('', 'system', 'SYSTEM', 'ct2', 1, '{"key": "value"}', 'c1', 'SYSTEM') @@ -20,88 +144,6 @@ ARRAY[ , ROW('289525561255060732', 'oidc_session', 'V2_289575178579535100', 'ct3', 1, '{"key": "value"}', 'c1', '289575074711790844') , ROW('', 'system', 'SYSTEM', 'ct3', 1, '{"key": "value"}', 'c1', 'SYSTEM') ]::eventstore.command[] -); +) )c; */ -CREATE OR REPLACE FUNCTION eventstore.commands_to_events(commands eventstore.command[]) RETURNS SETOF eventstore.events2 VOLATILE AS $$ -SELECT - ("c").instance_id - , ("c").aggregate_type - , ("c").aggregate_id - , ("c").command_type AS event_type - , cs.sequence + ROW_NUMBER() OVER (PARTITION BY ("c").instance_id, ("c").aggregate_type, ("c").aggregate_id ORDER BY ("c").in_tx_order) AS sequence - , ("c").revision - , hlc_to_timestamp(cluster_logical_timestamp()) AS created_at - , ("c").payload - , ("c").creator - , cs.owner - , cluster_logical_timestamp() AS position - , ("c").in_tx_order -FROM ( - SELECT - ("c").instance_id - , ("c").aggregate_type - , ("c").aggregate_id - , ("c").command_type - , ("c").revision - , ("c").payload - , ("c").creator - , ("c").owner - , ROW_NUMBER() OVER () AS in_tx_order - FROM - UNNEST(commands) AS "c" -) AS "c" -JOIN ( - SELECT - cmds.instance_id - , cmds.aggregate_type - , cmds.aggregate_id - , CASE WHEN (e.owner <> '') THEN e.owner ELSE command_owners.owner END AS owner - , COALESCE(MAX(e.sequence), 0) AS sequence - FROM ( - SELECT DISTINCT - ("cmds").instance_id - , ("cmds").aggregate_type - , ("cmds").aggregate_id - , ("cmds").owner - FROM UNNEST(commands) AS "cmds" - ) AS cmds - LEFT JOIN eventstore.events2 AS e - ON cmds.instance_id = e.instance_id - AND cmds.aggregate_type = e.aggregate_type - AND cmds.aggregate_id = e.aggregate_id - JOIN ( - SELECT - DISTINCT ON ( - ("c").instance_id - , ("c").aggregate_type - , ("c").aggregate_id - ) - ("c").instance_id - , ("c").aggregate_type - , ("c").aggregate_id - , ("c").owner - FROM - UNNEST(commands) AS "c" - ) AS command_owners ON - cmds.instance_id = command_owners.instance_id - AND cmds.aggregate_type = command_owners.aggregate_type - AND cmds.aggregate_id = command_owners.aggregate_id - GROUP BY - cmds.instance_id - , cmds.aggregate_type - , cmds.aggregate_id - , 4 -- owner -) AS cs - ON ("c").instance_id = cs.instance_id - AND ("c").aggregate_type = cs.aggregate_type - AND ("c").aggregate_id = cs.aggregate_id -ORDER BY - in_tx_order -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION eventstore.push(commands eventstore.command[]) RETURNS SETOF eventstore.events2 AS $$ - INSERT INTO eventstore.events2 - SELECT * FROM eventstore.commands_to_events(commands) - RETURNING * -$$ LANGUAGE SQL; \ No newline at end of file diff --git a/e2e/config/localhost/docker-compose.yaml b/e2e/config/localhost/docker-compose.yaml index 040cbc81c0..f90ee158f0 100644 --- a/e2e/config/localhost/docker-compose.yaml +++ b/e2e/config/localhost/docker-compose.yaml @@ -30,7 +30,7 @@ services: db: restart: 'always' - image: 'cockroachdb/cockroach:v24.2.1' + image: 'cockroachdb/cockroach:latest' command: 'start-single-node --insecure --http-addr :9090' healthcheck: test: ['CMD', 'curl', '-f', 'http://localhost:9090/health?ready=1'] From 3f6ea78c87fab52e4197d197369b9ccef266eeb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Thu, 16 Jan 2025 11:09:15 +0100 Subject: [PATCH 24/30] perf: role permissions in database (#9152) # Which Problems Are Solved Currently ZITADEL defines organization and instance member roles and permissions in defaults.yaml. The permission check is done on API call level. For example: "is this user allowed to make this call on this org". This makes sense on the V1 API where the API is permission-level shaped. For example, a search for users always happens in the context of the organization. (Either the organization the calling user belongs to, or through member ship and the x-zitadel-orgid header. However, for resource based APIs we must be able to resolve permissions by object. For example, an IAM_OWNER listing users should be able to get all users in an instance based on the query filters. Alternatively a user may have user.read permissions on one or more orgs. They should be able to read just those users. # How the Problems Are Solved ## Role permission mapping The role permission mappings defined from `defaults.yaml` or local config override are synchronized to the database on every run of `zitadel setup`: - A single query per **aggregate** builds a list of `add` and `remove` actions needed to reach the desired state or role permission mappings from the config. - The required events based on the actions are pushed to the event store. - Events define search fields so that permission checking can use the indices and is strongly consistent for both query and command sides. The migration is split in the following aggregates: - System aggregate for for roles prefixed with `SYSTEM` - Each instance for roles not prefixed with `SYSTEM`. This is in anticipation of instance level management over the API. ## Membership Current instance / org / project membership events now have field table definitions. Like the role permissions this ensures strong consistency while still being able to use the indices of the fields table. A migration is provided to fill the membership fields. ## Permission check I aimed keeping the mental overhead to the developer to a minimal. The provided implementation only provides a permission check for list queries for org level resources, for example users. In the `query` package there is a simple helper function `wherePermittedOrgs` which makes sure the underlying database function is called as part of the `SELECT` query and the permitted organizations are part of the `WHERE` clause. This makes sure results from non-permitted organizations are omitted. Under the hood: - A Pg/PlSQL function searches for a list of organization IDs the passed user has the passed permission. - When the user has the permission on instance level, it returns early with all organizations. - The functions uses a number of views. The views help mapping the fields entries into relational data and simplify the code use for the function. The views provide some pre-filters which allow proper index usage once the final `WHERE` clauses are set by the function. # Additional Changes # Additional Context Closes #9032 Closes https://github.com/zitadel/zitadel/issues/9014 https://github.com/zitadel/zitadel/issues/9188 defines follow-ups for the new permission framework based on this concept. --- cmd/defaults.yaml | 4 + cmd/setup/46.go | 39 +++++ cmd/setup/46/01-role_permissions_view.sql | 6 + cmd/setup/46/02-instance_orgs_view.sql | 6 + cmd/setup/46/03-instance_members_view.sql | 6 + cmd/setup/46/04-org_members_view.sql | 6 + cmd/setup/46/05-project_members_view.sql | 6 + cmd/setup/46/06-permitted_orgs_function.sql | 50 +++++++ cmd/setup/config.go | 4 + cmd/setup/setup.go | 6 + cmd/setup/sync_role_permissions.go | 134 ++++++++++++++++++ cmd/setup/sync_role_permissions.sql | 52 +++++++ cmd/start/config.go | 3 + internal/api/grpc/feature/v2/converter.go | 4 + .../api/grpc/feature/v2/converter_test.go | 16 +++ internal/command/instance.go | 18 +-- internal/command/instance_features.go | 4 +- internal/command/instance_features_model.go | 5 + internal/command/instance_permissions.go | 29 ++++ internal/command/system_features.go | 4 +- internal/command/system_features_model.go | 5 + internal/feature/feature.go | 2 + internal/feature/key_enumer.go | 12 +- internal/query/instance_features.go | 1 + internal/query/instance_features_model.go | 3 + internal/query/permission.go | 35 +++++ .../query/projection/instance_features.go | 4 + internal/query/projection/system_features.go | 4 + internal/query/system_features.go | 1 + internal/query/system_features_model.go | 3 + internal/query/user.go | 17 ++- .../feature/feature_v2/eventstore.go | 2 + .../repository/feature/feature_v2/feature.go | 2 + internal/repository/instance/member.go | 22 ++- internal/repository/member/events.go | 92 ++++++++++++ internal/repository/org/member.go | 22 ++- internal/repository/permission/aggregate.go | 22 +++ internal/repository/permission/permission.go | 114 +++++++++++++++ internal/repository/project/member.go | 20 +++ proto/zitadel/feature/v2/instance.proto | 13 ++ proto/zitadel/feature/v2/system.proto | 13 ++ 41 files changed, 789 insertions(+), 22 deletions(-) create mode 100644 cmd/setup/46.go create mode 100644 cmd/setup/46/01-role_permissions_view.sql create mode 100644 cmd/setup/46/02-instance_orgs_view.sql create mode 100644 cmd/setup/46/03-instance_members_view.sql create mode 100644 cmd/setup/46/04-org_members_view.sql create mode 100644 cmd/setup/46/05-project_members_view.sql create mode 100644 cmd/setup/46/06-permitted_orgs_function.sql create mode 100644 cmd/setup/sync_role_permissions.go create mode 100644 cmd/setup/sync_role_permissions.sql create mode 100644 internal/command/instance_permissions.go create mode 100644 internal/query/permission.go create mode 100644 internal/repository/permission/aggregate.go create mode 100644 internal/repository/permission/permission.go diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index 74ffffafcd..4176747ee6 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -1132,6 +1132,7 @@ DefaultInstance: LoginDefaultOrg: true # ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINDEFAULTORG # TriggerIntrospectionProjections: false # ZITADEL_DEFAULTINSTANCE_FEATURES_TRIGGERINTROSPECTIONPROJECTIONS # LegacyIntrospection: false # ZITADEL_DEFAULTINSTANCE_FEATURES_LEGACYINTROSPECTION + # PermissionCheckV2: false # ZITADEL_DEFAULTINSTANCE_FEATURES_PERMISSIONCHECKV2 Limits: # AuditLogRetention limits the number of events that can be queried via the events API by their age. # A value of "0s" means that all events are available. @@ -1195,6 +1196,9 @@ InternalAuthZ: # Configure the RolePermissionMappings by environment variable using JSON notation: # ZITADEL_INTERNALAUTHZ_ROLEPERMISSIONMAPPINGS='[{"role": "IAM_OWNER", "permissions": ["iam.write"]}, {"role": "ORG_OWNER", "permissions": ["org.write"]}]' # Beware that if you configure the RolePermissionMappings by environment variable, all the default RolePermissionMappings are lost. + # + # Warning: RolePermissionMappings are synhronized to the database. + # Changes here will only be applied after running `zitadel setup` or `zitadel start-from-setup`. RolePermissionMappings: - Role: "SYSTEM_OWNER" Permissions: diff --git a/cmd/setup/46.go b/cmd/setup/46.go new file mode 100644 index 0000000000..e48b16e4b0 --- /dev/null +++ b/cmd/setup/46.go @@ -0,0 +1,39 @@ +package setup + +import ( + "context" + "embed" + "fmt" + + "github.com/zitadel/logging" + + "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/eventstore" +) + +type InitPermissionFunctions struct { + eventstoreClient *database.DB +} + +var ( + //go:embed 46/*.sql + permissionFunctions embed.FS +) + +func (mig *InitPermissionFunctions) Execute(ctx context.Context, _ eventstore.Event) error { + statements, err := readStatements(permissionFunctions, "46", "") + if err != nil { + return err + } + for _, stmt := range statements { + logging.WithFields("file", stmt.file, "migration", mig.String()).Info("execute statement") + if _, err := mig.eventstoreClient.ExecContext(ctx, stmt.query); err != nil { + return fmt.Errorf("%s %s: %w", mig.String(), stmt.file, err) + } + } + return nil +} + +func (*InitPermissionFunctions) String() string { + return "46_init_permission_functions" +} diff --git a/cmd/setup/46/01-role_permissions_view.sql b/cmd/setup/46/01-role_permissions_view.sql new file mode 100644 index 0000000000..f0a8413125 --- /dev/null +++ b/cmd/setup/46/01-role_permissions_view.sql @@ -0,0 +1,6 @@ +CREATE OR REPLACE VIEW eventstore.role_permissions AS +SELECT instance_id, aggregate_id, object_id as role, text_value as permission +FROM eventstore.fields +WHERE aggregate_type = 'permission' +AND object_type = 'role_permission' +AND field_name = 'permission'; diff --git a/cmd/setup/46/02-instance_orgs_view.sql b/cmd/setup/46/02-instance_orgs_view.sql new file mode 100644 index 0000000000..aa59fcde6a --- /dev/null +++ b/cmd/setup/46/02-instance_orgs_view.sql @@ -0,0 +1,6 @@ +CREATE OR REPLACE VIEW eventstore.instance_orgs AS +SELECT instance_id, aggregate_id as org_id +FROM eventstore.fields +WHERE aggregate_type = 'org' +AND object_type = 'org' +AND field_name = 'state'; diff --git a/cmd/setup/46/03-instance_members_view.sql b/cmd/setup/46/03-instance_members_view.sql new file mode 100644 index 0000000000..cf47610f42 --- /dev/null +++ b/cmd/setup/46/03-instance_members_view.sql @@ -0,0 +1,6 @@ +CREATE OR REPLACE VIEW eventstore.instance_members AS +SELECT instance_id, object_id as user_id, text_value as role +FROM eventstore.fields +WHERE aggregate_type = 'instance' +AND object_type = 'instance_member_role' +AND field_name = 'instance_role'; diff --git a/cmd/setup/46/04-org_members_view.sql b/cmd/setup/46/04-org_members_view.sql new file mode 100644 index 0000000000..7477d9a816 --- /dev/null +++ b/cmd/setup/46/04-org_members_view.sql @@ -0,0 +1,6 @@ +CREATE OR REPLACE VIEW eventstore.org_members AS +SELECT instance_id, aggregate_id as org_id, object_id as user_id, text_value as role +FROM eventstore.fields +WHERE aggregate_type = 'org' +AND object_type = 'org_member_role' +AND field_name = 'org_role'; diff --git a/cmd/setup/46/05-project_members_view.sql b/cmd/setup/46/05-project_members_view.sql new file mode 100644 index 0000000000..0eed48cec3 --- /dev/null +++ b/cmd/setup/46/05-project_members_view.sql @@ -0,0 +1,6 @@ +CREATE OR REPLACE VIEW eventstore.project_members AS +SELECT instance_id, aggregate_id as project_id, object_id as user_id, text_value as role +FROM eventstore.fields +WHERE aggregate_type = 'project' +AND object_type = 'project_member_role' +AND field_name = 'project_role'; diff --git a/cmd/setup/46/06-permitted_orgs_function.sql b/cmd/setup/46/06-permitted_orgs_function.sql new file mode 100644 index 0000000000..55d63c1a19 --- /dev/null +++ b/cmd/setup/46/06-permitted_orgs_function.sql @@ -0,0 +1,50 @@ +CREATE OR REPLACE FUNCTION eventstore.permitted_orgs( + instanceId TEXT + , userId TEXT + , perm TEXT + + , org_ids OUT TEXT[] +) + LANGUAGE 'plpgsql' + STABLE +AS $$ +DECLARE + matched_roles TEXT[]; -- roles containing permission +BEGIN + SELECT array_agg(rp.role) INTO matched_roles + FROM eventstore.role_permissions rp + WHERE rp.instance_id = instanceId + AND rp.permission = perm; + + -- First try if the permission was granted thru an instance-level role + DECLARE + has_instance_permission bool; + BEGIN + SELECT true INTO has_instance_permission + FROM eventstore.instance_members im + WHERE im.role = ANY(matched_roles) + AND im.instance_id = instanceId + AND im.user_id = userId + LIMIT 1; + + IF has_instance_permission THEN + -- Return all organizations + SELECT array_agg(o.org_id) INTO org_ids + FROM eventstore.instance_orgs o + WHERE o.instance_id = instanceId; + RETURN; + END IF; + END; + + -- Return the organizations where permission were granted thru org-level roles + SELECT array_agg(org_id) INTO org_ids + FROM ( + SELECT DISTINCT om.org_id + FROM eventstore.org_members om + WHERE om.role = ANY(matched_roles) + AND om.instance_id = instanceID + AND om.user_id = userId + ); + RETURN; +END; +$$; diff --git a/cmd/setup/config.go b/cmd/setup/config.go index 0a5493b771..6d9443fae0 100644 --- a/cmd/setup/config.go +++ b/cmd/setup/config.go @@ -87,6 +87,9 @@ func MustNewConfig(v *viper.Viper) *Config { id.Configure(config.Machine) + // Copy the global role permissions mappings to the instance until we allow instance-level configuration over the API. + config.DefaultInstance.RolePermissionMappings = config.InternalAuthZ.RolePermissionMappings + return config } @@ -131,6 +134,7 @@ type Steps struct { s43CreateFieldsDomainIndex *CreateFieldsDomainIndex s44ReplaceCurrentSequencesIndex *ReplaceCurrentSequencesIndex s45CorrectProjectOwners *CorrectProjectOwners + s46InitPermissionFunctions *InitPermissionFunctions } func MustNewSteps(v *viper.Viper) *Steps { diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index d55ea0f3fe..33dba00602 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -174,6 +174,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s43CreateFieldsDomainIndex = &CreateFieldsDomainIndex{dbClient: queryDBClient} steps.s44ReplaceCurrentSequencesIndex = &ReplaceCurrentSequencesIndex{dbClient: esPusherDBClient} steps.s45CorrectProjectOwners = &CorrectProjectOwners{eventstore: eventstoreClient} + steps.s46InitPermissionFunctions = &InitPermissionFunctions{eventstoreClient: esPusherDBClient} err = projection.Create(ctx, projectionDBClient, eventstoreClient, config.Projections, nil, nil, nil) logging.OnError(err).Fatal("unable to start projections") @@ -196,6 +197,10 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) &FillFieldsForInstanceDomains{ eventstore: eventstoreClient, }, + &SyncRolePermissions{ + eventstore: eventstoreClient, + rolePermissionMappings: config.InternalAuthZ.RolePermissionMappings, + }, } for _, step := range []migration.Migration{ @@ -229,6 +234,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s38BackChannelLogoutNotificationStart, steps.s44ReplaceCurrentSequencesIndex, steps.s45CorrectProjectOwners, + steps.s46InitPermissionFunctions, } { mustExecuteMigration(ctx, eventstoreClient, step, "migration failed") } diff --git a/cmd/setup/sync_role_permissions.go b/cmd/setup/sync_role_permissions.go new file mode 100644 index 0000000000..b38b075d82 --- /dev/null +++ b/cmd/setup/sync_role_permissions.go @@ -0,0 +1,134 @@ +package setup + +import ( + "context" + "database/sql" + _ "embed" + "fmt" + "strings" + + "github.com/zitadel/logging" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/instance" + "github.com/zitadel/zitadel/internal/repository/permission" +) + +var ( + //go:embed sync_role_permissions.sql + getRolePermissionOperationsQuery string +) + +// SyncRolePermissions is a repeatable step which synchronizes the InternalAuthZ +// RolePermissionMappings from the configuration to the database. +// This is needed until role permissions are manageable over the API. +type SyncRolePermissions struct { + eventstore *eventstore.Eventstore + rolePermissionMappings []authz.RoleMapping +} + +func (mig *SyncRolePermissions) Execute(ctx context.Context, _ eventstore.Event) error { + if err := mig.executeSystem(ctx); err != nil { + return err + } + return mig.executeInstances(ctx) +} + +func (mig *SyncRolePermissions) executeSystem(ctx context.Context) error { + logging.WithFields("migration", mig.String()).Info("prepare system role permission sync events") + + target := rolePermissionMappingsToDatabaseMap(mig.rolePermissionMappings, true) + cmds, err := mig.synchronizeCommands(ctx, "SYSTEM", target) + if err != nil { + return err + } + events, err := mig.eventstore.Push(ctx, cmds...) + if err != nil { + return err + } + + logging.WithFields("migration", mig.String(), "pushed_events", len(events)).Info("pushed system role permission sync events") + return nil +} + +func (mig *SyncRolePermissions) executeInstances(ctx context.Context) error { + instances, err := mig.eventstore.InstanceIDs( + ctx, + eventstore.NewSearchQueryBuilder(eventstore.ColumnsInstanceIDs). + OrderDesc(). + AddQuery(). + AggregateTypes(instance.AggregateType). + EventTypes(instance.InstanceAddedEventType). + Builder(). + ExcludeAggregateIDs(). + AggregateTypes(instance.AggregateType). + EventTypes(instance.InstanceRemovedEventType). + Builder(), + ) + if err != nil { + return err + } + target := rolePermissionMappingsToDatabaseMap(mig.rolePermissionMappings, false) + for i, instanceID := range instances { + logging.WithFields("instance_id", instanceID, "migration", mig.String(), "progress", fmt.Sprintf("%d/%d", i+1, len(instances))).Info("prepare instance role permission sync events") + cmds, err := mig.synchronizeCommands(ctx, instanceID, target) + if err != nil { + return err + } + events, err := mig.eventstore.Push(ctx, cmds...) + if err != nil { + return err + } + logging.WithFields("instance_id", instanceID, "migration", mig.String(), "pushed_events", len(events)).Info("pushed instance role permission sync events") + } + return nil +} + +// synchronizeCommands checks the current state of role permissions in the eventstore for the aggregate. +// It returns the commands required to reach the desired state passed in target. +// For system level permissions aggregateID must be set to `SYSTEM`, +// else it is the instance ID. +func (mig *SyncRolePermissions) synchronizeCommands(ctx context.Context, aggregateID string, target database.Map[[]string]) (cmds []eventstore.Command, err error) { + aggregate := permission.NewAggregate(aggregateID) + err = mig.eventstore.Client().QueryContext(ctx, func(rows *sql.Rows) error { + for rows.Next() { + var operation, role, perm string + if err := rows.Scan(&operation, &role, &perm); err != nil { + return err + } + logging.WithFields("aggregate_id", aggregateID, "migration", mig.String(), "operation", operation, "role", role, "permission", perm).Debug("sync role permission") + switch operation { + case "add": + cmds = append(cmds, permission.NewAddedEvent(ctx, aggregate, role, perm)) + case "remove": + cmds = append(cmds, permission.NewRemovedEvent(ctx, aggregate, role, perm)) + } + } + return rows.Close() + + }, getRolePermissionOperationsQuery, aggregateID, target) + if err != nil { + return nil, err + } + return cmds, err +} + +func (*SyncRolePermissions) String() string { + return "repeatable_sync_role_permissions" +} + +func (*SyncRolePermissions) Check(lastRun map[string]interface{}) bool { + return true +} + +func rolePermissionMappingsToDatabaseMap(mappings []authz.RoleMapping, system bool) database.Map[[]string] { + out := make(database.Map[[]string], len(mappings)) + for _, m := range mappings { + if system == strings.HasPrefix(m.Role, "SYSTEM") { + out[m.Role] = m.Permissions + } + } + return out +} diff --git a/cmd/setup/sync_role_permissions.sql b/cmd/setup/sync_role_permissions.sql new file mode 100644 index 0000000000..e7ce21cee7 --- /dev/null +++ b/cmd/setup/sync_role_permissions.sql @@ -0,0 +1,52 @@ +/* +This query creates a change set of permissions that need to be added or removed. +It compares the current state in the fields table (thru the role_permissions view) +against a passed role permission mapping as JSON, created from Zitadel's config: + +{ + "IAM_ADMIN_IMPERSONATOR": ["admin.impersonation", "impersonation"], + "IAM_END_USER_IMPERSONATOR": ["impersonation"], + "FOO_BAR": ["foo.bar", "bar.foo"] + } + +It uses an aggregate_id as first argument which may be an instance_id or 'SYSTEM' +for system level permissions. +*/ +WITH target AS ( + -- unmarshal JSON representation into flattened tabular data + SELECT + key AS role, + jsonb_array_elements_text(value) AS permission + FROM jsonb_each($2::jsonb) +), add AS ( + -- find all role permissions that exist in `target` and not in `role_permissions` + SELECT t.role, t.permission + FROM eventstore.role_permissions p + RIGHT JOIN target t + ON p.aggregate_id = $1::text + AND p.role = t.role + AND p.permission = t.permission + WHERE p.role IS NULL +), remove AS ( + -- find all role permissions that exist `role_permissions` and not in `target` + SELECT p.role, p.permission + FROM eventstore.role_permissions p + LEFT JOIN target t + ON p.role = t.role + AND p.permission = t.permission + WHERE p.aggregate_id = $1::text + AND t.role IS NULL +) +-- return the required operations +SELECT + 'add' AS operation, + role, + permission +FROM add +UNION ALL +SELECT + 'remove' AS operation, + role, + permission +FROM remove +; diff --git a/cmd/start/config.go b/cmd/start/config.go index d63b8a319a..910759b653 100644 --- a/cmd/start/config.go +++ b/cmd/start/config.go @@ -127,5 +127,8 @@ func MustNewConfig(v *viper.Viper) *Config { id.Configure(config.Machine) actions.SetHTTPConfig(&config.Actions.HTTP) + // Copy the global role permissions mappings to the instance until we allow instance-level configuration over the API. + config.DefaultInstance.RolePermissionMappings = config.InternalAuthZ.RolePermissionMappings + return config } diff --git a/internal/api/grpc/feature/v2/converter.go b/internal/api/grpc/feature/v2/converter.go index 109d2d1e53..fee4450ce2 100644 --- a/internal/api/grpc/feature/v2/converter.go +++ b/internal/api/grpc/feature/v2/converter.go @@ -29,6 +29,7 @@ func systemFeaturesToCommand(req *feature_pb.SetSystemFeaturesRequest) (*command DisableUserTokenEvent: req.DisableUserTokenEvent, EnableBackChannelLogout: req.EnableBackChannelLogout, LoginV2: loginV2, + PermissionCheckV2: req.PermissionCheckV2, }, nil } @@ -46,6 +47,7 @@ func systemFeaturesToPb(f *query.SystemFeatures) *feature_pb.GetSystemFeaturesRe DisableUserTokenEvent: featureSourceToFlagPb(&f.DisableUserTokenEvent), EnableBackChannelLogout: featureSourceToFlagPb(&f.EnableBackChannelLogout), LoginV2: loginV2ToLoginV2FlagPb(f.LoginV2), + PermissionCheckV2: featureSourceToFlagPb(&f.PermissionCheckV2), } } @@ -68,6 +70,7 @@ func instanceFeaturesToCommand(req *feature_pb.SetInstanceFeaturesRequest) (*com DisableUserTokenEvent: req.DisableUserTokenEvent, EnableBackChannelLogout: req.EnableBackChannelLogout, LoginV2: loginV2, + PermissionCheckV2: req.PermissionCheckV2, }, nil } @@ -87,6 +90,7 @@ func instanceFeaturesToPb(f *query.InstanceFeatures) *feature_pb.GetInstanceFeat DisableUserTokenEvent: featureSourceToFlagPb(&f.DisableUserTokenEvent), EnableBackChannelLogout: featureSourceToFlagPb(&f.EnableBackChannelLogout), LoginV2: loginV2ToLoginV2FlagPb(f.LoginV2), + PermissionCheckV2: featureSourceToFlagPb(&f.PermissionCheckV2), } } diff --git a/internal/api/grpc/feature/v2/converter_test.go b/internal/api/grpc/feature/v2/converter_test.go index f8b2c0006f..bf87dc959b 100644 --- a/internal/api/grpc/feature/v2/converter_test.go +++ b/internal/api/grpc/feature/v2/converter_test.go @@ -101,6 +101,10 @@ func Test_systemFeaturesToPb(t *testing.T) { BaseURI: &url.URL{Scheme: "https", Host: "login.com"}, }, }, + PermissionCheckV2: query.FeatureSource[bool]{ + Level: feature.LevelSystem, + Value: true, + }, } want := &feature_pb.GetSystemFeaturesResponse{ Details: &object.Details{ @@ -153,6 +157,10 @@ func Test_systemFeaturesToPb(t *testing.T) { BaseUri: gu.Ptr("https://login.com"), Source: feature_pb.Source_SOURCE_SYSTEM, }, + PermissionCheckV2: &feature_pb.FeatureFlag{ + Enabled: true, + Source: feature_pb.Source_SOURCE_SYSTEM, + }, } got := systemFeaturesToPb(arg) assert.Equal(t, want, got) @@ -252,6 +260,10 @@ func Test_instanceFeaturesToPb(t *testing.T) { BaseURI: &url.URL{Scheme: "https", Host: "login.com"}, }, }, + PermissionCheckV2: query.FeatureSource[bool]{ + Level: feature.LevelInstance, + Value: true, + }, } want := &feature_pb.GetInstanceFeaturesResponse{ Details: &object.Details{ @@ -312,6 +324,10 @@ func Test_instanceFeaturesToPb(t *testing.T) { BaseUri: gu.Ptr("https://login.com"), Source: feature_pb.Source_SOURCE_INSTANCE, }, + PermissionCheckV2: &feature_pb.FeatureFlag{ + Enabled: true, + Source: feature_pb.Source_SOURCE_INSTANCE, + }, } got := instanceFeaturesToPb(arg) assert.Equal(t, want, got) diff --git a/internal/command/instance.go b/internal/command/instance.go index c5ac4d8472..144378ce58 100644 --- a/internal/command/instance.go +++ b/internal/command/instance.go @@ -116,14 +116,15 @@ type InstanceSetup struct { MaxOTPAttempts uint64 ShouldShowLockoutFailure bool } - EmailTemplate []byte - MessageTexts []*domain.CustomMessageText - SMTPConfiguration *SMTPConfiguration - OIDCSettings *OIDCSettings - Quotas *SetQuotas - Features *InstanceFeatures - Limits *SetLimits - Restrictions *SetRestrictions + EmailTemplate []byte + MessageTexts []*domain.CustomMessageText + SMTPConfiguration *SMTPConfiguration + OIDCSettings *OIDCSettings + Quotas *SetQuotas + Features *InstanceFeatures + Limits *SetLimits + Restrictions *SetRestrictions + RolePermissionMappings []authz.RoleMapping } type SMTPConfiguration struct { @@ -379,6 +380,7 @@ func setupInstanceElements(instanceAgg *instance.Aggregate, setup *InstanceSetup setup.LabelPolicy.ThemeMode, ), prepareAddDefaultEmailTemplate(instanceAgg, setup.EmailTemplate), + prepareAddRolePermissions(instanceAgg, setup.RolePermissionMappings), } } diff --git a/internal/command/instance_features.go b/internal/command/instance_features.go index 44f122e98f..1f714671bd 100644 --- a/internal/command/instance_features.go +++ b/internal/command/instance_features.go @@ -29,6 +29,7 @@ type InstanceFeatures struct { DisableUserTokenEvent *bool EnableBackChannelLogout *bool LoginV2 *feature.LoginV2 + PermissionCheckV2 *bool } func (m *InstanceFeatures) isEmpty() bool { @@ -45,7 +46,8 @@ func (m *InstanceFeatures) isEmpty() bool { m.OIDCSingleV1SessionTermination == nil && m.DisableUserTokenEvent == nil && m.EnableBackChannelLogout == nil && - m.LoginV2 == nil + m.LoginV2 == nil && + m.PermissionCheckV2 == nil } func (c *Commands) SetInstanceFeatures(ctx context.Context, f *InstanceFeatures) (*domain.ObjectDetails, error) { diff --git a/internal/command/instance_features_model.go b/internal/command/instance_features_model.go index 8fa52318db..aaa8b2e53a 100644 --- a/internal/command/instance_features_model.go +++ b/internal/command/instance_features_model.go @@ -79,6 +79,7 @@ func (m *InstanceFeaturesWriteModel) Query() *eventstore.SearchQueryBuilder { feature_v2.InstanceDisableUserTokenEvent, feature_v2.InstanceEnableBackChannelLogout, feature_v2.InstanceLoginVersion, + feature_v2.InstancePermissionCheckV2, ). Builder().ResourceOwner(m.ResourceOwner) } @@ -129,6 +130,9 @@ func reduceInstanceFeature(features *InstanceFeatures, key feature.Key, value an features.EnableBackChannelLogout = &v case feature.KeyLoginV2: features.LoginV2 = value.(*feature.LoginV2) + case feature.KeyPermissionCheckV2: + v := value.(bool) + features.PermissionCheckV2 = &v } } @@ -148,5 +152,6 @@ func (wm *InstanceFeaturesWriteModel) setCommands(ctx context.Context, f *Instan cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.DisableUserTokenEvent, f.DisableUserTokenEvent, feature_v2.InstanceDisableUserTokenEvent) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.EnableBackChannelLogout, f.EnableBackChannelLogout, feature_v2.InstanceEnableBackChannelLogout) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.LoginV2, f.LoginV2, feature_v2.InstanceLoginVersion) + cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.PermissionCheckV2, f.PermissionCheckV2, feature_v2.InstancePermissionCheckV2) return cmds } diff --git a/internal/command/instance_permissions.go b/internal/command/instance_permissions.go new file mode 100644 index 0000000000..c46c8f7c4a --- /dev/null +++ b/internal/command/instance_permissions.go @@ -0,0 +1,29 @@ +package command + +import ( + "context" + "strings" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/command/preparation" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/instance" + "github.com/zitadel/zitadel/internal/repository/permission" +) + +func prepareAddRolePermissions(a *instance.Aggregate, roles []authz.RoleMapping) preparation.Validation { + return func() (preparation.CreateCommands, error) { + return func(ctx context.Context, _ preparation.FilterToQueryReducer) (cmds []eventstore.Command, _ error) { + aggregate := permission.NewAggregate(a.InstanceID) + for _, r := range roles { + if strings.HasPrefix(r.Role, "SYSTEM") { + continue + } + for _, p := range r.Permissions { + cmds = append(cmds, permission.NewAddedEvent(ctx, aggregate, r.Role, p)) + } + } + return cmds, nil + }, nil + } +} diff --git a/internal/command/system_features.go b/internal/command/system_features.go index eb10bba553..dc886de318 100644 --- a/internal/command/system_features.go +++ b/internal/command/system_features.go @@ -21,6 +21,7 @@ type SystemFeatures struct { DisableUserTokenEvent *bool EnableBackChannelLogout *bool LoginV2 *feature.LoginV2 + PermissionCheckV2 *bool } func (m *SystemFeatures) isEmpty() bool { @@ -35,7 +36,8 @@ func (m *SystemFeatures) isEmpty() bool { m.OIDCSingleV1SessionTermination == nil && m.DisableUserTokenEvent == nil && m.EnableBackChannelLogout == nil && - m.LoginV2 == nil + m.LoginV2 == nil && + m.PermissionCheckV2 == nil } func (c *Commands) SetSystemFeatures(ctx context.Context, f *SystemFeatures) (*domain.ObjectDetails, error) { diff --git a/internal/command/system_features_model.go b/internal/command/system_features_model.go index d656a6e266..15fc3e0bf0 100644 --- a/internal/command/system_features_model.go +++ b/internal/command/system_features_model.go @@ -70,6 +70,7 @@ func (m *SystemFeaturesWriteModel) Query() *eventstore.SearchQueryBuilder { feature_v2.SystemDisableUserTokenEvent, feature_v2.SystemEnableBackChannelLogout, feature_v2.SystemLoginVersion, + feature_v2.SystemPermissionCheckV2, ). Builder().ResourceOwner(m.ResourceOwner) } @@ -113,6 +114,9 @@ func reduceSystemFeature(features *SystemFeatures, key feature.Key, value any) { features.EnableBackChannelLogout = &v case feature.KeyLoginV2: features.LoginV2 = value.(*feature.LoginV2) + case feature.KeyPermissionCheckV2: + v := value.(bool) + features.PermissionCheckV2 = &v } } @@ -130,6 +134,7 @@ func (wm *SystemFeaturesWriteModel) setCommands(ctx context.Context, f *SystemFe cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.DisableUserTokenEvent, f.DisableUserTokenEvent, feature_v2.SystemDisableUserTokenEvent) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.EnableBackChannelLogout, f.EnableBackChannelLogout, feature_v2.SystemEnableBackChannelLogout) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.LoginV2, f.LoginV2, feature_v2.SystemLoginVersion) + cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.PermissionCheckV2, f.PermissionCheckV2, feature_v2.SystemPermissionCheckV2) return cmds } diff --git a/internal/feature/feature.go b/internal/feature/feature.go index 09fdf2ff52..d9a2d6352d 100644 --- a/internal/feature/feature.go +++ b/internal/feature/feature.go @@ -23,6 +23,7 @@ const ( KeyDisableUserTokenEvent KeyEnableBackChannelLogout KeyLoginV2 + KeyPermissionCheckV2 ) //go:generate enumer -type Level -transform snake -trimprefix Level @@ -52,6 +53,7 @@ type Features struct { DisableUserTokenEvent bool `json:"disable_user_token_event,omitempty"` EnableBackChannelLogout bool `json:"enable_back_channel_logout,omitempty"` LoginV2 LoginV2 `json:"login_v2,omitempty"` + PermissionCheckV2 bool `json:"permission_check_v2,omitempty"` } type ImprovedPerformanceType int32 diff --git a/internal/feature/key_enumer.go b/internal/feature/key_enumer.go index 462b751e6c..3a805df807 100644 --- a/internal/feature/key_enumer.go +++ b/internal/feature/key_enumer.go @@ -7,11 +7,11 @@ import ( "strings" ) -const _KeyName = "unspecifiedlogin_default_orgtrigger_introspection_projectionslegacy_introspectionuser_schematoken_exchangeactionsimproved_performanceweb_keydebug_oidc_parent_erroroidc_single_v1_session_terminationdisable_user_token_eventenable_back_channel_logoutlogin_v2" +const _KeyName = "unspecifiedlogin_default_orgtrigger_introspection_projectionslegacy_introspectionuser_schematoken_exchangeactionsimproved_performanceweb_keydebug_oidc_parent_erroroidc_single_v1_session_terminationdisable_user_token_eventenable_back_channel_logoutlogin_v2permission_check_v2" -var _KeyIndex = [...]uint8{0, 11, 28, 61, 81, 92, 106, 113, 133, 140, 163, 197, 221, 247, 255} +var _KeyIndex = [...]uint16{0, 11, 28, 61, 81, 92, 106, 113, 133, 140, 163, 197, 221, 247, 255, 274} -const _KeyLowerName = "unspecifiedlogin_default_orgtrigger_introspection_projectionslegacy_introspectionuser_schematoken_exchangeactionsimproved_performanceweb_keydebug_oidc_parent_erroroidc_single_v1_session_terminationdisable_user_token_eventenable_back_channel_logoutlogin_v2" +const _KeyLowerName = "unspecifiedlogin_default_orgtrigger_introspection_projectionslegacy_introspectionuser_schematoken_exchangeactionsimproved_performanceweb_keydebug_oidc_parent_erroroidc_single_v1_session_terminationdisable_user_token_eventenable_back_channel_logoutlogin_v2permission_check_v2" func (i Key) String() string { if i < 0 || i >= Key(len(_KeyIndex)-1) { @@ -38,9 +38,10 @@ func _KeyNoOp() { _ = x[KeyDisableUserTokenEvent-(11)] _ = x[KeyEnableBackChannelLogout-(12)] _ = x[KeyLoginV2-(13)] + _ = x[KeyPermissionCheckV2-(14)] } -var _KeyValues = []Key{KeyUnspecified, KeyLoginDefaultOrg, KeyTriggerIntrospectionProjections, KeyLegacyIntrospection, KeyUserSchema, KeyTokenExchange, KeyActions, KeyImprovedPerformance, KeyWebKey, KeyDebugOIDCParentError, KeyOIDCSingleV1SessionTermination, KeyDisableUserTokenEvent, KeyEnableBackChannelLogout, KeyLoginV2} +var _KeyValues = []Key{KeyUnspecified, KeyLoginDefaultOrg, KeyTriggerIntrospectionProjections, KeyLegacyIntrospection, KeyUserSchema, KeyTokenExchange, KeyActions, KeyImprovedPerformance, KeyWebKey, KeyDebugOIDCParentError, KeyOIDCSingleV1SessionTermination, KeyDisableUserTokenEvent, KeyEnableBackChannelLogout, KeyLoginV2, KeyPermissionCheckV2} var _KeyNameToValueMap = map[string]Key{ _KeyName[0:11]: KeyUnspecified, @@ -71,6 +72,8 @@ var _KeyNameToValueMap = map[string]Key{ _KeyLowerName[221:247]: KeyEnableBackChannelLogout, _KeyName[247:255]: KeyLoginV2, _KeyLowerName[247:255]: KeyLoginV2, + _KeyName[255:274]: KeyPermissionCheckV2, + _KeyLowerName[255:274]: KeyPermissionCheckV2, } var _KeyNames = []string{ @@ -88,6 +91,7 @@ var _KeyNames = []string{ _KeyName[197:221], _KeyName[221:247], _KeyName[247:255], + _KeyName[255:274], } // KeyString retrieves an enum value from the enum constants string name. diff --git a/internal/query/instance_features.go b/internal/query/instance_features.go index 4f06577a6d..646404ce6c 100644 --- a/internal/query/instance_features.go +++ b/internal/query/instance_features.go @@ -22,6 +22,7 @@ type InstanceFeatures struct { DisableUserTokenEvent FeatureSource[bool] EnableBackChannelLogout FeatureSource[bool] LoginV2 FeatureSource[*feature.LoginV2] + PermissionCheckV2 FeatureSource[bool] } func (q *Queries) GetInstanceFeatures(ctx context.Context, cascade bool) (_ *InstanceFeatures, err error) { diff --git a/internal/query/instance_features_model.go b/internal/query/instance_features_model.go index c7f273a24a..b9839bf359 100644 --- a/internal/query/instance_features_model.go +++ b/internal/query/instance_features_model.go @@ -75,6 +75,7 @@ func (m *InstanceFeaturesReadModel) Query() *eventstore.SearchQueryBuilder { feature_v2.InstanceDisableUserTokenEvent, feature_v2.InstanceEnableBackChannelLogout, feature_v2.InstanceLoginVersion, + feature_v2.InstancePermissionCheckV2, ). Builder().ResourceOwner(m.ResourceOwner) } @@ -139,6 +140,8 @@ func reduceInstanceFeatureSet[T any](features *InstanceFeatures, event *feature_ features.EnableBackChannelLogout.set(level, event.Value) case feature.KeyLoginV2: features.LoginV2.set(level, event.Value) + case feature.KeyPermissionCheckV2: + features.PermissionCheckV2.set(level, event.Value) } return nil } diff --git a/internal/query/permission.go b/internal/query/permission.go new file mode 100644 index 0000000000..96d7db6c6a --- /dev/null +++ b/internal/query/permission.go @@ -0,0 +1,35 @@ +package query + +import ( + "context" + "fmt" + + sq "github.com/Masterminds/squirrel" + "github.com/zitadel/logging" + + "github.com/zitadel/zitadel/internal/api/authz" +) + +const ( + // eventstore.permitted_orgs(instanceid text, userid text, perm text) + wherePermittedOrgsClause = "%s = ANY(eventstore.permitted_orgs(?, ?, ?))" +) + +// wherePermittedOrgs sets a `WHERE` clause to the query that filters the orgs +// for which the authenticated user has the requested permission for. +// The user ID is taken from the context. +// +// The `orgIDColumn` specifies the table column to which this filter must be applied, +// and is typically the `resource_owner` column in ZITADEL. +// We use full identifiers in the query builder so this function should be +// called with something like `UserResourceOwnerCol.identifier()` for example. +func wherePermittedOrgs(ctx context.Context, query sq.SelectBuilder, orgIDColumn, permission string) sq.SelectBuilder { + userID := authz.GetCtxData(ctx).UserID + logging.WithFields("permission_check_v2_flag", authz.GetFeatures(ctx).PermissionCheckV2, "org_id_column", orgIDColumn, "permission", permission, "user_id", userID).Debug("permitted orgs check used") + return query.Where( + fmt.Sprintf(wherePermittedOrgsClause, orgIDColumn), + authz.GetInstance(ctx).InstanceID(), + userID, + permission, + ) +} diff --git a/internal/query/projection/instance_features.go b/internal/query/projection/instance_features.go index 2479203d09..2cd846bf2e 100644 --- a/internal/query/projection/instance_features.go +++ b/internal/query/projection/instance_features.go @@ -112,6 +112,10 @@ func (*instanceFeatureProjection) Reducers() []handler.AggregateReducer { Event: feature_v2.InstanceLoginVersion, Reduce: reduceInstanceSetFeature[*feature.LoginV2], }, + { + Event: feature_v2.InstancePermissionCheckV2, + Reduce: reduceInstanceSetFeature[bool], + }, { Event: instance.InstanceRemovedEventType, Reduce: reduceInstanceRemovedHelper(InstanceDomainInstanceIDCol), diff --git a/internal/query/projection/system_features.go b/internal/query/projection/system_features.go index 410234c27c..f6f0a36d56 100644 --- a/internal/query/projection/system_features.go +++ b/internal/query/projection/system_features.go @@ -92,6 +92,10 @@ func (*systemFeatureProjection) Reducers() []handler.AggregateReducer { Event: feature_v2.SystemLoginVersion, Reduce: reduceSystemSetFeature[*feature.LoginV2], }, + { + Event: feature_v2.SystemPermissionCheckV2, + Reduce: reduceSystemSetFeature[bool], + }, }, }} } diff --git a/internal/query/system_features.go b/internal/query/system_features.go index e696f6bf6f..31ad402d12 100644 --- a/internal/query/system_features.go +++ b/internal/query/system_features.go @@ -31,6 +31,7 @@ type SystemFeatures struct { DisableUserTokenEvent FeatureSource[bool] EnableBackChannelLogout FeatureSource[bool] LoginV2 FeatureSource[*feature.LoginV2] + PermissionCheckV2 FeatureSource[bool] } func (q *Queries) GetSystemFeatures(ctx context.Context) (_ *SystemFeatures, err error) { diff --git a/internal/query/system_features_model.go b/internal/query/system_features_model.go index f486e1ba4a..217154e3ed 100644 --- a/internal/query/system_features_model.go +++ b/internal/query/system_features_model.go @@ -66,6 +66,7 @@ func (m *SystemFeaturesReadModel) Query() *eventstore.SearchQueryBuilder { feature_v2.SystemDisableUserTokenEvent, feature_v2.SystemEnableBackChannelLogout, feature_v2.SystemLoginVersion, + feature_v2.SystemPermissionCheckV2, ). Builder().ResourceOwner(m.ResourceOwner) } @@ -105,6 +106,8 @@ func reduceSystemFeatureSet[T any](features *SystemFeatures, event *feature_v2.S features.EnableBackChannelLogout.set(level, event.Value) case feature.KeyLoginV2: features.LoginV2.set(level, event.Value) + case feature.KeyPermissionCheckV2: + features.PermissionCheckV2.set(level, event.Value) } return nil } diff --git a/internal/query/user.go b/internal/query/user.go index 415e50aae5..9f29ec77b3 100644 --- a/internal/query/user.go +++ b/internal/query/user.go @@ -605,24 +605,29 @@ func (q *Queries) GetNotifyUser(ctx context.Context, shouldTriggered bool, queri } func (q *Queries) SearchUsers(ctx context.Context, queries *UserSearchQueries, permissionCheck domain.PermissionCheck) (*Users, error) { - users, err := q.searchUsers(ctx, queries) + users, err := q.searchUsers(ctx, queries, permissionCheck != nil && authz.GetFeatures(ctx).PermissionCheckV2) if err != nil { return nil, err } - if permissionCheck != nil { + if permissionCheck != nil && !authz.GetFeatures(ctx).PermissionCheckV2 { usersCheckPermission(ctx, users, permissionCheck) } return users, nil } -func (q *Queries) searchUsers(ctx context.Context, queries *UserSearchQueries) (users *Users, err error) { +func (q *Queries) searchUsers(ctx context.Context, queries *UserSearchQueries, permissionCheckV2 bool) (users *Users, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() query, scan := prepareUsersQuery(ctx, q.client) - eq := sq.Eq{UserInstanceIDCol.identifier(): authz.GetInstance(ctx).InstanceID()} - stmt, args, err := queries.toQuery(query).Where(eq). - ToSql() + query = queries.toQuery(query).Where(sq.Eq{ + UserInstanceIDCol.identifier(): authz.GetInstance(ctx).InstanceID(), + }) + if permissionCheckV2 { + query = wherePermittedOrgs(ctx, query, UserResourceOwnerCol.identifier(), domain.PermissionUserRead) + } + + stmt, args, err := query.ToSql() if err != nil { return nil, zerrors.ThrowInternal(err, "QUERY-Dgbg2", "Errors.Query.SQLStatment") } diff --git a/internal/repository/feature/feature_v2/eventstore.go b/internal/repository/feature/feature_v2/eventstore.go index d4d2617aea..f5e033af1c 100644 --- a/internal/repository/feature/feature_v2/eventstore.go +++ b/internal/repository/feature/feature_v2/eventstore.go @@ -18,6 +18,7 @@ func init() { eventstore.RegisterFilterEventMapper(AggregateType, SystemDisableUserTokenEvent, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, SystemEnableBackChannelLogout, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, SystemLoginVersion, eventstore.GenericEventMapper[SetEvent[*feature.LoginV2]]) + eventstore.RegisterFilterEventMapper(AggregateType, SystemPermissionCheckV2, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceResetEventType, eventstore.GenericEventMapper[ResetEvent]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceLoginDefaultOrgEventType, eventstore.GenericEventMapper[SetEvent[bool]]) @@ -33,4 +34,5 @@ func init() { eventstore.RegisterFilterEventMapper(AggregateType, InstanceDisableUserTokenEvent, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceEnableBackChannelLogout, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceLoginVersion, eventstore.GenericEventMapper[SetEvent[*feature.LoginV2]]) + eventstore.RegisterFilterEventMapper(AggregateType, InstancePermissionCheckV2, eventstore.GenericEventMapper[SetEvent[bool]]) } diff --git a/internal/repository/feature/feature_v2/feature.go b/internal/repository/feature/feature_v2/feature.go index 0255203bdd..331a5143f9 100644 --- a/internal/repository/feature/feature_v2/feature.go +++ b/internal/repository/feature/feature_v2/feature.go @@ -23,6 +23,7 @@ var ( SystemDisableUserTokenEvent = setEventTypeFromFeature(feature.LevelSystem, feature.KeyDisableUserTokenEvent) SystemEnableBackChannelLogout = setEventTypeFromFeature(feature.LevelSystem, feature.KeyEnableBackChannelLogout) SystemLoginVersion = setEventTypeFromFeature(feature.LevelSystem, feature.KeyLoginV2) + SystemPermissionCheckV2 = setEventTypeFromFeature(feature.LevelSystem, feature.KeyPermissionCheckV2) InstanceResetEventType = resetEventTypeFromFeature(feature.LevelInstance) InstanceLoginDefaultOrgEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyLoginDefaultOrg) @@ -38,6 +39,7 @@ var ( InstanceDisableUserTokenEvent = setEventTypeFromFeature(feature.LevelInstance, feature.KeyDisableUserTokenEvent) InstanceEnableBackChannelLogout = setEventTypeFromFeature(feature.LevelInstance, feature.KeyEnableBackChannelLogout) InstanceLoginVersion = setEventTypeFromFeature(feature.LevelInstance, feature.KeyLoginV2) + InstancePermissionCheckV2 = setEventTypeFromFeature(feature.LevelInstance, feature.KeyPermissionCheckV2) ) const ( diff --git a/internal/repository/instance/member.go b/internal/repository/instance/member.go index 0518aab47f..161bdcdaec 100644 --- a/internal/repository/instance/member.go +++ b/internal/repository/instance/member.go @@ -7,17 +7,25 @@ import ( "github.com/zitadel/zitadel/internal/repository/member" ) -var ( +const ( MemberAddedEventType = instanceEventTypePrefix + member.AddedEventType MemberChangedEventType = instanceEventTypePrefix + member.ChangedEventType MemberRemovedEventType = instanceEventTypePrefix + member.RemovedEventType MemberCascadeRemovedEventType = instanceEventTypePrefix + member.CascadeRemovedEventType ) +const ( + fieldPrefix = "instance" +) + type MemberAddedEvent struct { member.MemberAddedEvent } +func (e *MemberAddedEvent) Fields() []*eventstore.FieldOperation { + return e.FieldOperations(fieldPrefix) +} + func NewMemberAddedEvent( ctx context.Context, aggregate *eventstore.Aggregate, @@ -51,6 +59,10 @@ type MemberChangedEvent struct { member.MemberChangedEvent } +func (e *MemberChangedEvent) Fields() []*eventstore.FieldOperation { + return e.FieldOperations(fieldPrefix) +} + func NewMemberChangedEvent( ctx context.Context, aggregate *eventstore.Aggregate, @@ -83,6 +95,10 @@ type MemberRemovedEvent struct { member.MemberRemovedEvent } +func (e *MemberRemovedEvent) Fields() []*eventstore.FieldOperation { + return e.FieldOperations(fieldPrefix) +} + func NewMemberRemovedEvent( ctx context.Context, aggregate *eventstore.Aggregate, @@ -113,6 +129,10 @@ type MemberCascadeRemovedEvent struct { member.MemberCascadeRemovedEvent } +func (e *MemberCascadeRemovedEvent) Fields() []*eventstore.FieldOperation { + return e.FieldOperations(fieldPrefix) +} + func NewMemberCascadeRemovedEvent( ctx context.Context, aggregate *eventstore.Aggregate, diff --git a/internal/repository/member/events.go b/internal/repository/member/events.go index 0c98b46a41..5d0a28c243 100644 --- a/internal/repository/member/events.go +++ b/internal/repository/member/events.go @@ -7,6 +7,7 @@ import ( "github.com/zitadel/zitadel/internal/zerrors" ) +// Event types const ( UniqueMember = "member" AddedEventType = "member.added" @@ -15,6 +16,13 @@ const ( CascadeRemovedEventType = "member.cascade.removed" ) +// Field table and unique types +const ( + memberRoleTypeSuffix string = "_member_role" + MemberRoleRevision uint8 = 1 + roleSearchFieldSuffix string = "_role" +) + func NewAddMemberUniqueConstraint(aggregateID, userID string) *eventstore.UniqueConstraint { return eventstore.NewAddEventUniqueConstraint( UniqueMember, @@ -44,6 +52,32 @@ func (e *MemberAddedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { return []*eventstore.UniqueConstraint{NewAddMemberUniqueConstraint(e.Aggregate().ID, e.UserID)} } +func (e *MemberAddedEvent) FieldOperations(prefix string) []*eventstore.FieldOperation { + ops := make([]*eventstore.FieldOperation, len(e.Roles)) + for i, role := range e.Roles { + ops[i] = eventstore.SetField( + e.Aggregate(), + memberSearchObject(prefix, e.UserID), + prefix+roleSearchFieldSuffix, + &eventstore.Value{ + Value: role, + MustBeUnique: false, + ShouldIndex: true, + }, + + eventstore.FieldTypeInstanceID, + eventstore.FieldTypeResourceOwner, + eventstore.FieldTypeAggregateType, + eventstore.FieldTypeAggregateID, + eventstore.FieldTypeObjectType, + eventstore.FieldTypeObjectID, + eventstore.FieldTypeFieldName, + eventstore.FieldTypeValue, + ) + } + return ops +} + func NewMemberAddedEvent( base *eventstore.BaseEvent, userID string, @@ -85,6 +119,38 @@ func (e *MemberChangedEvent) UniqueConstraints() []*eventstore.UniqueConstraint return nil } +// FieldOperations removes the existing membership role fields first and sets the new roles after. +func (e *MemberChangedEvent) FieldOperations(prefix string) []*eventstore.FieldOperation { + ops := make([]*eventstore.FieldOperation, len(e.Roles)+1) + ops[0] = eventstore.RemoveSearchFieldsByAggregateAndObject( + e.Aggregate(), + memberSearchObject(prefix, e.UserID), + ) + + for i, role := range e.Roles { + ops[i+1] = eventstore.SetField( + e.Aggregate(), + memberSearchObject(prefix, e.UserID), + prefix+roleSearchFieldSuffix, + &eventstore.Value{ + Value: role, + MustBeUnique: false, + ShouldIndex: true, + }, + + eventstore.FieldTypeInstanceID, + eventstore.FieldTypeResourceOwner, + eventstore.FieldTypeAggregateType, + eventstore.FieldTypeAggregateID, + eventstore.FieldTypeObjectType, + eventstore.FieldTypeObjectID, + eventstore.FieldTypeFieldName, + eventstore.FieldTypeValue, + ) + } + return ops +} + func NewMemberChangedEvent( base *eventstore.BaseEvent, userID string, @@ -124,6 +190,15 @@ func (e *MemberRemovedEvent) UniqueConstraints() []*eventstore.UniqueConstraint return []*eventstore.UniqueConstraint{NewRemoveMemberUniqueConstraint(e.Aggregate().ID, e.UserID)} } +func (e *MemberRemovedEvent) FieldOperations(prefix string) []*eventstore.FieldOperation { + return []*eventstore.FieldOperation{ + eventstore.RemoveSearchFieldsByAggregateAndObject( + e.Aggregate(), + memberSearchObject(prefix, e.UserID), + ), + } +} + func NewRemovedEvent( base *eventstore.BaseEvent, userID string, @@ -162,6 +237,15 @@ func (e *MemberCascadeRemovedEvent) UniqueConstraints() []*eventstore.UniqueCons return []*eventstore.UniqueConstraint{NewRemoveMemberUniqueConstraint(e.Aggregate().ID, e.UserID)} } +func (e *MemberCascadeRemovedEvent) FieldOperations(prefix string) []*eventstore.FieldOperation { + return []*eventstore.FieldOperation{ + eventstore.RemoveSearchFieldsByAggregateAndObject( + e.Aggregate(), + memberSearchObject(prefix, e.UserID), + ), + } +} + func NewCascadeRemovedEvent( base *eventstore.BaseEvent, userID string, @@ -185,3 +269,11 @@ func CascadeRemovedEventMapper(event eventstore.Event) (eventstore.Event, error) return e, nil } + +func memberSearchObject(prefix, userID string) eventstore.Object { + return eventstore.Object{ + Type: prefix + memberRoleTypeSuffix, + ID: userID, + Revision: MemberRoleRevision, + } +} diff --git a/internal/repository/org/member.go b/internal/repository/org/member.go index 81a4d5850f..5068a274b8 100644 --- a/internal/repository/org/member.go +++ b/internal/repository/org/member.go @@ -7,17 +7,25 @@ import ( "github.com/zitadel/zitadel/internal/repository/member" ) -var ( +const ( MemberAddedEventType = orgEventTypePrefix + member.AddedEventType MemberChangedEventType = orgEventTypePrefix + member.ChangedEventType MemberRemovedEventType = orgEventTypePrefix + member.RemovedEventType MemberCascadeRemovedEventType = orgEventTypePrefix + member.CascadeRemovedEventType ) +const ( + fieldPrefix = "org" +) + type MemberAddedEvent struct { member.MemberAddedEvent } +func (e *MemberAddedEvent) Fields() []*eventstore.FieldOperation { + return e.FieldOperations(fieldPrefix) +} + func NewMemberAddedEvent( ctx context.Context, aggregate *eventstore.Aggregate, @@ -50,6 +58,10 @@ type MemberChangedEvent struct { member.MemberChangedEvent } +func (e *MemberChangedEvent) Fields() []*eventstore.FieldOperation { + return e.FieldOperations(fieldPrefix) +} + func NewMemberChangedEvent( ctx context.Context, aggregate *eventstore.Aggregate, @@ -83,6 +95,10 @@ type MemberRemovedEvent struct { member.MemberRemovedEvent } +func (e *MemberRemovedEvent) Fields() []*eventstore.FieldOperation { + return e.FieldOperations(fieldPrefix) +} + func NewMemberRemovedEvent( ctx context.Context, aggregate *eventstore.Aggregate, @@ -113,6 +129,10 @@ type MemberCascadeRemovedEvent struct { member.MemberCascadeRemovedEvent } +func (e *MemberCascadeRemovedEvent) Fields() []*eventstore.FieldOperation { + return e.FieldOperations(fieldPrefix) +} + func NewMemberCascadeRemovedEvent( ctx context.Context, aggregate *eventstore.Aggregate, diff --git a/internal/repository/permission/aggregate.go b/internal/repository/permission/aggregate.go new file mode 100644 index 0000000000..a0ac199102 --- /dev/null +++ b/internal/repository/permission/aggregate.go @@ -0,0 +1,22 @@ +package permission + +import "github.com/zitadel/zitadel/internal/eventstore" + +const ( + AggregateType eventstore.AggregateType = "permission" + AggregateVersion eventstore.Version = "v1" +) + +func NewAggregate(aggregateID string) *eventstore.Aggregate { + var instanceID string + if aggregateID != "SYSTEM" { + instanceID = aggregateID + } + return &eventstore.Aggregate{ + ID: aggregateID, + Type: AggregateType, + ResourceOwner: aggregateID, + InstanceID: instanceID, + Version: AggregateVersion, + } +} diff --git a/internal/repository/permission/permission.go b/internal/repository/permission/permission.go new file mode 100644 index 0000000000..a02a4dca0a --- /dev/null +++ b/internal/repository/permission/permission.go @@ -0,0 +1,114 @@ +package permission + +import ( + "context" + + "github.com/zitadel/zitadel/internal/eventstore" +) + +// Event types +const ( + permissionEventPrefix eventstore.EventType = "permission." + AddedType = permissionEventPrefix + "added" + RemovedType = permissionEventPrefix + "removed" +) + +// Field table and unique types +const ( + RolePermissionType string = "role_permission" + RolePermissionRevision uint8 = 1 + PermissionSearchField string = "permission" +) + +type AddedEvent struct { + *eventstore.BaseEvent `json:"-"` + Role string `json:"role"` + Permission string `json:"permission"` +} + +func (e *AddedEvent) Payload() interface{} { + return e +} + +func (e *AddedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func (e *AddedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event +} + +func (e *AddedEvent) Fields() []*eventstore.FieldOperation { + return []*eventstore.FieldOperation{ + eventstore.SetField( + e.Aggregate(), + roleSearchObject(e.Role), + PermissionSearchField, + &eventstore.Value{ + Value: e.Permission, + MustBeUnique: false, + ShouldIndex: true, + }, + + eventstore.FieldTypeInstanceID, + eventstore.FieldTypeResourceOwner, + eventstore.FieldTypeAggregateType, + eventstore.FieldTypeAggregateID, + eventstore.FieldTypeObjectType, + eventstore.FieldTypeObjectID, + eventstore.FieldTypeFieldName, + eventstore.FieldTypeValue, + ), + } +} + +func NewAddedEvent(ctx context.Context, aggregate *eventstore.Aggregate, role, permission string) *AddedEvent { + return &AddedEvent{ + BaseEvent: eventstore.NewBaseEventForPush(ctx, aggregate, AddedType), + Role: role, + Permission: permission, + } +} + +type RemovedEvent struct { + *eventstore.BaseEvent `json:"-"` + Role string `json:"role"` + Permission string `json:"permission"` +} + +func (e *RemovedEvent) Payload() interface{} { + return e +} + +func (e *RemovedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func (e *RemovedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event +} + +func (e *RemovedEvent) Fields() []*eventstore.FieldOperation { + return []*eventstore.FieldOperation{ + eventstore.RemoveSearchFieldsByAggregateAndObject( + e.Aggregate(), + roleSearchObject(e.Role), + ), + } +} + +func NewRemovedEvent(ctx context.Context, aggregate *eventstore.Aggregate, role, permission string) *RemovedEvent { + return &RemovedEvent{ + BaseEvent: eventstore.NewBaseEventForPush(ctx, aggregate, AddedType), + Role: role, + Permission: permission, + } +} + +func roleSearchObject(role string) eventstore.Object { + return eventstore.Object{ + Type: RolePermissionType, + ID: role, + Revision: RolePermissionRevision, + } +} diff --git a/internal/repository/project/member.go b/internal/repository/project/member.go index d2928bfdc2..d04709b5fa 100644 --- a/internal/repository/project/member.go +++ b/internal/repository/project/member.go @@ -14,10 +14,18 @@ var ( MemberCascadeRemovedType = projectEventTypePrefix + member.CascadeRemovedEventType ) +const ( + fieldPrefix = "project" +) + type MemberAddedEvent struct { member.MemberAddedEvent } +func (e *MemberAddedEvent) Fields() []*eventstore.FieldOperation { + return e.FieldOperations(fieldPrefix) +} + func NewProjectMemberAddedEvent( ctx context.Context, aggregate *eventstore.Aggregate, @@ -50,6 +58,10 @@ type MemberChangedEvent struct { member.MemberChangedEvent } +func (e *MemberChangedEvent) Fields() []*eventstore.FieldOperation { + return e.FieldOperations(fieldPrefix) +} + func NewProjectMemberChangedEvent( ctx context.Context, aggregate *eventstore.Aggregate, @@ -83,6 +95,10 @@ type MemberRemovedEvent struct { member.MemberRemovedEvent } +func (e *MemberRemovedEvent) Fields() []*eventstore.FieldOperation { + return e.FieldOperations(fieldPrefix) +} + func NewProjectMemberRemovedEvent( ctx context.Context, aggregate *eventstore.Aggregate, @@ -114,6 +130,10 @@ type MemberCascadeRemovedEvent struct { member.MemberCascadeRemovedEvent } +func (e *MemberCascadeRemovedEvent) Fields() []*eventstore.FieldOperation { + return e.FieldOperations(fieldPrefix) +} + func NewProjectMemberCascadeRemovedEvent( ctx context.Context, aggregate *eventstore.Aggregate, diff --git a/proto/zitadel/feature/v2/instance.proto b/proto/zitadel/feature/v2/instance.proto index 385ce5a4d0..3d2280fc0c 100644 --- a/proto/zitadel/feature/v2/instance.proto +++ b/proto/zitadel/feature/v2/instance.proto @@ -99,6 +99,13 @@ message SetInstanceFeaturesRequest{ description: "Specify the login UI for all users and applications regardless of their preference."; } ]; + + optional bool permission_check_v2 = 14 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "true"; + description: "Enable a newer, more performant, permission check used for v2 and v3 resource based APIs."; + } + ]; } message SetInstanceFeaturesResponse { @@ -212,4 +219,10 @@ message GetInstanceFeaturesResponse { description: "If the flag is set, all users will be redirected to the login V2 regardless of the application's preference."; } ]; + + FeatureFlag permission_check_v2 = 15 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Enable a newer, more performant, permission check used for v2 and v3 resource based APIs."; + } + ]; } diff --git a/proto/zitadel/feature/v2/system.proto b/proto/zitadel/feature/v2/system.proto index cac8fe774f..c734905fb2 100644 --- a/proto/zitadel/feature/v2/system.proto +++ b/proto/zitadel/feature/v2/system.proto @@ -88,6 +88,13 @@ message SetSystemFeaturesRequest{ description: "Specify the login UI for all users and applications regardless of their preference."; } ]; + + optional bool permission_check_v2 = 12 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "true"; + description: "Enable a newer, more performant, permission check used for v2 and v3 resource based APIs."; + } + ]; } message SetSystemFeaturesResponse { @@ -180,4 +187,10 @@ message GetSystemFeaturesResponse { description: "If the flag is set, all users will be redirected to the login V2 regardless of the application's preference."; } ]; + + FeatureFlag permission_check_v2 = 13 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Enable a newer, more performant, permission check used for v2 and v3 resource based APIs."; + } + ]; } From 07f74730ac44d77b81fba39dfb10624764f11029 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 16 Jan 2025 11:34:52 +0100 Subject: [PATCH 25/30] fix: include tzdata to validate timezones in scim (#9195) # Which Problems Are Solved - include tzdata in the binary to correctly validate time zones in the scim layer if the os doesn't have timezone data available. # How the Problems Are Solved - by importing the go pkg `"time/tzdata"` # Additional Context Part of https://github.com/zitadel/zitadel/issues/8140 --- internal/api/scim/resources/user_metadata.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/api/scim/resources/user_metadata.go b/internal/api/scim/resources/user_metadata.go index 1bb00ff8a0..d08594c3cf 100644 --- a/internal/api/scim/resources/user_metadata.go +++ b/internal/api/scim/resources/user_metadata.go @@ -4,6 +4,9 @@ import ( "context" "encoding/json" "time" + // import timezone database to ensure it is available at runtime + // data is required to validate time zones. + _ "time/tzdata" "github.com/zitadel/logging" "golang.org/x/text/language" From 4645045987daea50a7549fcb41c16c87e8aae388 Mon Sep 17 00:00:00 2001 From: Silvan <27845747+adlerhurst@users.noreply.github.com> Date: Thu, 16 Jan 2025 12:07:18 +0100 Subject: [PATCH 26/30] refactor: consolidate database pools (#9105) # Which Problems Are Solved Zitadel currently uses 3 database pool, 1 for queries, 1 for pushing events and 1 for scheduled projection updates. This defeats the purpose of a connection pool which already handles multiple connections. During load tests we found that the current structure of connection pools consumes a lot of database resources. The resource usage dropped after we reduced the amount of database pools to 1 because existing connections can be used more efficiently. # How the Problems Are Solved Removed logic to handle multiple connection pools and use a single one. # Additional Changes none # Additional Context part of https://github.com/zitadel/zitadel/issues/8352 --- cmd/defaults.yaml | 15 +- cmd/initialise/init.go | 3 +- cmd/initialise/verify_zitadel.go | 3 +- cmd/key/key.go | 3 +- cmd/mirror/auth.go | 5 +- cmd/mirror/event_store.go | 5 +- cmd/mirror/projections.go | 7 +- cmd/mirror/system.go | 5 +- cmd/mirror/verify.go | 5 +- cmd/setup/cleanup.go | 9 +- cmd/setup/setup.go | 95 ++++--- cmd/start/start.go | 39 ++- internal/database/cockroach/crdb.go | 35 +-- internal/database/database.go | 10 +- internal/database/dialect/config.go | 29 +- internal/database/dialect/config_test.go | 36 --- internal/database/dialect/connections.go | 68 +---- internal/database/dialect/connections_test.go | 252 ------------------ internal/database/postgres/pg.go | 35 +-- internal/eventstore/repository/sql/query.go | 2 +- internal/eventstore/v3/push.go | 7 + 21 files changed, 104 insertions(+), 564 deletions(-) delete mode 100644 internal/database/dialect/config_test.go delete mode 100644 internal/database/dialect/connections_test.go diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index 4176747ee6..326dcc69a8 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -110,24 +110,13 @@ PublicHostHeaders: # ZITADEL_PUBLICHOSTHEADERS WebAuthNName: ZITADEL # ZITADEL_WEBAUTHNNAME Database: - # ZITADEL manages three database connection pools. - # The *ConnRatio settings define the ratio of how many connections from - # MaxOpenConns and MaxIdleConns are used to push events and spool projections. - # Remaining connection are used for queries (search). - # Values may not be negative and the sum of the ratios must always be less than 1. - # For example this defaults define 15 MaxOpenConns overall. - # - 15*0.2=3 connections are allocated to the event pusher; - # - 15*0.135=2 connections are allocated to the projection spooler; - # - 15-(3+2)=10 connections are remaining for queries; - EventPushConnRatio: 0.2 # ZITADEL_DATABASE_COCKROACH_EVENTPUSHCONNRATIO - ProjectionSpoolerConnRatio: 0.135 # ZITADEL_DATABASE_COCKROACH_PROJECTIONSPOOLERCONNRATIO # CockroachDB is the default database of ZITADEL cockroach: Host: localhost # ZITADEL_DATABASE_COCKROACH_HOST Port: 26257 # ZITADEL_DATABASE_COCKROACH_PORT Database: zitadel # ZITADEL_DATABASE_COCKROACH_DATABASE - MaxOpenConns: 15 # ZITADEL_DATABASE_COCKROACH_MAXOPENCONNS - MaxIdleConns: 12 # ZITADEL_DATABASE_COCKROACH_MAXIDLECONNS + MaxOpenConns: 5 # ZITADEL_DATABASE_COCKROACH_MAXOPENCONNS + MaxIdleConns: 2 # ZITADEL_DATABASE_COCKROACH_MAXIDLECONNS MaxConnLifetime: 30m # ZITADEL_DATABASE_COCKROACH_MAXCONNLIFETIME MaxConnIdleTime: 5m # ZITADEL_DATABASE_COCKROACH_MAXCONNIDLETIME Options: "" # ZITADEL_DATABASE_COCKROACH_OPTIONS diff --git a/cmd/initialise/init.go b/cmd/initialise/init.go index fba5098fa2..02fd481eab 100644 --- a/cmd/initialise/init.go +++ b/cmd/initialise/init.go @@ -9,7 +9,6 @@ import ( "github.com/zitadel/logging" "github.com/zitadel/zitadel/internal/database" - "github.com/zitadel/zitadel/internal/database/dialect" ) var ( @@ -79,7 +78,7 @@ func initialise(ctx context.Context, config database.Config, steps ...func(conte return err } - db, err := database.Connect(config, true, dialect.DBPurposeQuery) + db, err := database.Connect(config, true) if err != nil { return err } diff --git a/cmd/initialise/verify_zitadel.go b/cmd/initialise/verify_zitadel.go index a5ce1fd57c..1ae85a21fa 100644 --- a/cmd/initialise/verify_zitadel.go +++ b/cmd/initialise/verify_zitadel.go @@ -11,7 +11,6 @@ import ( "github.com/zitadel/logging" "github.com/zitadel/zitadel/internal/database" - "github.com/zitadel/zitadel/internal/database/dialect" es_v3 "github.com/zitadel/zitadel/internal/eventstore/v3" ) @@ -85,7 +84,7 @@ func VerifyZitadel(ctx context.Context, db *database.DB, config database.Config) func verifyZitadel(ctx context.Context, config database.Config) error { logging.WithFields("database", config.DatabaseName()).Info("verify zitadel") - db, err := database.Connect(config, false, dialect.DBPurposeQuery) + db, err := database.Connect(config, false) if err != nil { return err } diff --git a/cmd/key/key.go b/cmd/key/key.go index 2691932784..1dba8fd969 100644 --- a/cmd/key/key.go +++ b/cmd/key/key.go @@ -12,7 +12,6 @@ import ( "github.com/zitadel/zitadel/internal/crypto" cryptoDB "github.com/zitadel/zitadel/internal/crypto/database" "github.com/zitadel/zitadel/internal/database" - "github.com/zitadel/zitadel/internal/database/dialect" "github.com/zitadel/zitadel/internal/zerrors" ) @@ -124,7 +123,7 @@ func openFile(fileName string) (io.Reader, error) { } func keyStorage(config database.Config, masterKey string) (crypto.KeyStorage, error) { - db, err := database.Connect(config, false, dialect.DBPurposeQuery) + db, err := database.Connect(config, false) if err != nil { return nil, err } diff --git a/cmd/mirror/auth.go b/cmd/mirror/auth.go index df94708e71..0eba10d05f 100644 --- a/cmd/mirror/auth.go +++ b/cmd/mirror/auth.go @@ -12,7 +12,6 @@ import ( "github.com/zitadel/logging" "github.com/zitadel/zitadel/internal/database" - "github.com/zitadel/zitadel/internal/database/dialect" ) func authCmd() *cobra.Command { @@ -34,11 +33,11 @@ Only auth requests are mirrored`, } func copyAuth(ctx context.Context, config *Migration) { - sourceClient, err := database.Connect(config.Source, false, dialect.DBPurposeQuery) + sourceClient, err := database.Connect(config.Source, false) logging.OnError(err).Fatal("unable to connect to source database") defer sourceClient.Close() - destClient, err := database.Connect(config.Destination, false, dialect.DBPurposeEventPusher) + destClient, err := database.Connect(config.Destination, false) logging.OnError(err).Fatal("unable to connect to destination database") defer destClient.Close() diff --git a/cmd/mirror/event_store.go b/cmd/mirror/event_store.go index 23145bdc37..3825462126 100644 --- a/cmd/mirror/event_store.go +++ b/cmd/mirror/event_store.go @@ -14,7 +14,6 @@ import ( "github.com/zitadel/logging" db "github.com/zitadel/zitadel/internal/database" - "github.com/zitadel/zitadel/internal/database/dialect" "github.com/zitadel/zitadel/internal/id" "github.com/zitadel/zitadel/internal/v2/database" "github.com/zitadel/zitadel/internal/v2/eventstore" @@ -44,11 +43,11 @@ Migrate only copies events2 and unique constraints`, } func copyEventstore(ctx context.Context, config *Migration) { - sourceClient, err := db.Connect(config.Source, false, dialect.DBPurposeEventPusher) + sourceClient, err := db.Connect(config.Source, false) logging.OnError(err).Fatal("unable to connect to source database") defer sourceClient.Close() - destClient, err := db.Connect(config.Destination, false, dialect.DBPurposeEventPusher) + destClient, err := db.Connect(config.Destination, false) logging.OnError(err).Fatal("unable to connect to destination database") defer destClient.Close() diff --git a/cmd/mirror/projections.go b/cmd/mirror/projections.go index ae903d90c5..a4987a48f6 100644 --- a/cmd/mirror/projections.go +++ b/cmd/mirror/projections.go @@ -30,7 +30,6 @@ import ( "github.com/zitadel/zitadel/internal/config/systemdefaults" crypto_db "github.com/zitadel/zitadel/internal/crypto/database" "github.com/zitadel/zitadel/internal/database" - "github.com/zitadel/zitadel/internal/database/dialect" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" old_es "github.com/zitadel/zitadel/internal/eventstore/repository/sql" @@ -106,7 +105,7 @@ func projections( ) { start := time.Now() - client, err := database.Connect(config.Destination, false, dialect.DBPurposeQuery) + client, err := database.Connect(config.Destination, false) logging.OnError(err).Fatal("unable to connect to database") keyStorage, err := crypto_db.NewKeyStorage(client, masterKey) @@ -119,9 +118,7 @@ func projections( logging.OnError(err).Fatal("unable create static storage") config.Eventstore.Querier = old_es.NewCRDB(client) - esPusherDBClient, err := database.Connect(config.Destination, false, dialect.DBPurposeEventPusher) - logging.OnError(err).Fatal("unable to connect eventstore push client") - config.Eventstore.Pusher = new_es.NewEventstore(esPusherDBClient) + config.Eventstore.Pusher = new_es.NewEventstore(client) es := eventstore.NewEventstore(config.Eventstore) esV4 := es_v4.NewEventstoreFromOne(es_v4_pg.New(client, &es_v4_pg.Config{ MaxRetries: config.Eventstore.MaxRetries, diff --git a/cmd/mirror/system.go b/cmd/mirror/system.go index e16836aa8c..00b48eb491 100644 --- a/cmd/mirror/system.go +++ b/cmd/mirror/system.go @@ -12,7 +12,6 @@ import ( "github.com/zitadel/logging" "github.com/zitadel/zitadel/internal/database" - "github.com/zitadel/zitadel/internal/database/dialect" ) func systemCmd() *cobra.Command { @@ -34,11 +33,11 @@ Only keys and assets are mirrored`, } func copySystem(ctx context.Context, config *Migration) { - sourceClient, err := database.Connect(config.Source, false, dialect.DBPurposeQuery) + sourceClient, err := database.Connect(config.Source, false) logging.OnError(err).Fatal("unable to connect to source database") defer sourceClient.Close() - destClient, err := database.Connect(config.Destination, false, dialect.DBPurposeEventPusher) + destClient, err := database.Connect(config.Destination, false) logging.OnError(err).Fatal("unable to connect to destination database") defer destClient.Close() diff --git a/cmd/mirror/verify.go b/cmd/mirror/verify.go index 68c927d091..e1a507d9fe 100644 --- a/cmd/mirror/verify.go +++ b/cmd/mirror/verify.go @@ -13,7 +13,6 @@ import ( cryptoDatabase "github.com/zitadel/zitadel/internal/crypto/database" "github.com/zitadel/zitadel/internal/database" - "github.com/zitadel/zitadel/internal/database/dialect" "github.com/zitadel/zitadel/internal/query/projection" ) @@ -37,11 +36,11 @@ var schemas = []string{ } func verifyMigration(ctx context.Context, config *Migration) { - sourceClient, err := database.Connect(config.Source, false, dialect.DBPurposeQuery) + sourceClient, err := database.Connect(config.Source, false) logging.OnError(err).Fatal("unable to connect to source database") defer sourceClient.Close() - destClient, err := database.Connect(config.Destination, false, dialect.DBPurposeEventPusher) + destClient, err := database.Connect(config.Destination, false) logging.OnError(err).Fatal("unable to connect to destination database") defer destClient.Close() diff --git a/cmd/setup/cleanup.go b/cmd/setup/cleanup.go index e9bc832d21..943ac164ea 100644 --- a/cmd/setup/cleanup.go +++ b/cmd/setup/cleanup.go @@ -8,7 +8,6 @@ import ( "github.com/zitadel/logging" "github.com/zitadel/zitadel/internal/database" - "github.com/zitadel/zitadel/internal/database/dialect" "github.com/zitadel/zitadel/internal/eventstore" old_es "github.com/zitadel/zitadel/internal/eventstore/repository/sql" new_es "github.com/zitadel/zitadel/internal/eventstore/v3" @@ -32,13 +31,11 @@ func Cleanup(config *Config) { logging.Info("cleanup started") - queryDBClient, err := database.Connect(config.Database, false, dialect.DBPurposeQuery) - logging.OnError(err).Fatal("unable to connect to database") - esPusherDBClient, err := database.Connect(config.Database, false, dialect.DBPurposeEventPusher) + dbClient, err := database.Connect(config.Database, false) logging.OnError(err).Fatal("unable to connect to database") - config.Eventstore.Pusher = new_es.NewEventstore(esPusherDBClient) - config.Eventstore.Querier = old_es.NewCRDB(queryDBClient) + config.Eventstore.Pusher = new_es.NewEventstore(dbClient) + config.Eventstore.Querier = old_es.NewCRDB(dbClient) es := eventstore.NewEventstore(config.Eventstore) step, err := migration.LastStuckStep(ctx, es) diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index 33dba00602..cd9d3d9673 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -26,7 +26,6 @@ import ( "github.com/zitadel/zitadel/internal/command" cryptoDB "github.com/zitadel/zitadel/internal/crypto/database" "github.com/zitadel/zitadel/internal/database" - "github.com/zitadel/zitadel/internal/database/dialect" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" old_es "github.com/zitadel/zitadel/internal/eventstore/repository/sql" @@ -102,26 +101,22 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) i18n.MustLoadSupportedLanguagesFromDir() - queryDBClient, err := database.Connect(config.Database, false, dialect.DBPurposeQuery) - logging.OnError(err).Fatal("unable to connect to database") - esPusherDBClient, err := database.Connect(config.Database, false, dialect.DBPurposeEventPusher) - logging.OnError(err).Fatal("unable to connect to database") - projectionDBClient, err := database.Connect(config.Database, false, dialect.DBPurposeProjectionSpooler) + dbClient, err := database.Connect(config.Database, false) logging.OnError(err).Fatal("unable to connect to database") - config.Eventstore.Querier = old_es.NewCRDB(queryDBClient) - esV3 := new_es.NewEventstore(esPusherDBClient) + config.Eventstore.Querier = old_es.NewCRDB(dbClient) + esV3 := new_es.NewEventstore(dbClient) config.Eventstore.Pusher = esV3 config.Eventstore.Searcher = esV3 eventstoreClient := eventstore.NewEventstore(config.Eventstore) logging.OnError(err).Fatal("unable to start eventstore") - eventstoreV4 := es_v4.NewEventstoreFromOne(es_v4_pg.New(queryDBClient, &es_v4_pg.Config{ + eventstoreV4 := es_v4.NewEventstoreFromOne(es_v4_pg.New(dbClient, &es_v4_pg.Config{ MaxRetries: config.Eventstore.MaxRetries, })) - steps.s1ProjectionTable = &ProjectionTable{dbClient: queryDBClient.DB} - steps.s2AssetsTable = &AssetTable{dbClient: queryDBClient.DB} + steps.s1ProjectionTable = &ProjectionTable{dbClient: dbClient.DB} + steps.s2AssetsTable = &AssetTable{dbClient: dbClient.DB} steps.FirstInstance.Skip = config.ForMirror || steps.FirstInstance.Skip steps.FirstInstance.instanceSetup = config.DefaultInstance @@ -129,7 +124,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.FirstInstance.smtpEncryptionKey = config.EncryptionKeys.SMTP steps.FirstInstance.oidcEncryptionKey = config.EncryptionKeys.OIDC steps.FirstInstance.masterKey = masterKey - steps.FirstInstance.db = queryDBClient + steps.FirstInstance.db = dbClient steps.FirstInstance.es = eventstoreClient steps.FirstInstance.defaults = config.SystemDefaults steps.FirstInstance.zitadelRoles = config.InternalAuthZ.RolePermissionMappings @@ -137,46 +132,46 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.FirstInstance.externalSecure = config.ExternalSecure steps.FirstInstance.externalPort = config.ExternalPort - steps.s5LastFailed = &LastFailed{dbClient: queryDBClient.DB} - steps.s6OwnerRemoveColumns = &OwnerRemoveColumns{dbClient: queryDBClient.DB} - steps.s7LogstoreTables = &LogstoreTables{dbClient: queryDBClient.DB, username: config.Database.Username(), dbType: config.Database.Type()} - steps.s8AuthTokens = &AuthTokenIndexes{dbClient: queryDBClient} - steps.CorrectCreationDate.dbClient = esPusherDBClient - steps.s12AddOTPColumns = &AddOTPColumns{dbClient: queryDBClient} - steps.s13FixQuotaProjection = &FixQuotaConstraints{dbClient: queryDBClient} - steps.s14NewEventsTable = &NewEventsTable{dbClient: esPusherDBClient} - steps.s15CurrentStates = &CurrentProjectionState{dbClient: queryDBClient} - steps.s16UniqueConstraintsLower = &UniqueConstraintToLower{dbClient: queryDBClient} - steps.s17AddOffsetToUniqueConstraints = &AddOffsetToCurrentStates{dbClient: queryDBClient} - steps.s18AddLowerFieldsToLoginNames = &AddLowerFieldsToLoginNames{dbClient: queryDBClient} - steps.s19AddCurrentStatesIndex = &AddCurrentSequencesIndex{dbClient: queryDBClient} - steps.s20AddByUserSessionIndex = &AddByUserIndexToSession{dbClient: queryDBClient} - steps.s21AddBlockFieldToLimits = &AddBlockFieldToLimits{dbClient: queryDBClient} - steps.s22ActiveInstancesIndex = &ActiveInstanceEvents{dbClient: queryDBClient} - steps.s23CorrectGlobalUniqueConstraints = &CorrectGlobalUniqueConstraints{dbClient: esPusherDBClient} - steps.s24AddActorToAuthTokens = &AddActorToAuthTokens{dbClient: queryDBClient} - steps.s25User11AddLowerFieldsToVerifiedEmail = &User11AddLowerFieldsToVerifiedEmail{dbClient: esPusherDBClient} - steps.s26AuthUsers3 = &AuthUsers3{dbClient: esPusherDBClient} - steps.s27IDPTemplate6SAMLNameIDFormat = &IDPTemplate6SAMLNameIDFormat{dbClient: esPusherDBClient} - steps.s28AddFieldTable = &AddFieldTable{dbClient: esPusherDBClient} + steps.s5LastFailed = &LastFailed{dbClient: dbClient.DB} + steps.s6OwnerRemoveColumns = &OwnerRemoveColumns{dbClient: dbClient.DB} + steps.s7LogstoreTables = &LogstoreTables{dbClient: dbClient.DB, username: config.Database.Username(), dbType: config.Database.Type()} + steps.s8AuthTokens = &AuthTokenIndexes{dbClient: dbClient} + steps.CorrectCreationDate.dbClient = dbClient + steps.s12AddOTPColumns = &AddOTPColumns{dbClient: dbClient} + steps.s13FixQuotaProjection = &FixQuotaConstraints{dbClient: dbClient} + steps.s14NewEventsTable = &NewEventsTable{dbClient: dbClient} + steps.s15CurrentStates = &CurrentProjectionState{dbClient: dbClient} + steps.s16UniqueConstraintsLower = &UniqueConstraintToLower{dbClient: dbClient} + steps.s17AddOffsetToUniqueConstraints = &AddOffsetToCurrentStates{dbClient: dbClient} + steps.s18AddLowerFieldsToLoginNames = &AddLowerFieldsToLoginNames{dbClient: dbClient} + steps.s19AddCurrentStatesIndex = &AddCurrentSequencesIndex{dbClient: dbClient} + steps.s20AddByUserSessionIndex = &AddByUserIndexToSession{dbClient: dbClient} + steps.s21AddBlockFieldToLimits = &AddBlockFieldToLimits{dbClient: dbClient} + steps.s22ActiveInstancesIndex = &ActiveInstanceEvents{dbClient: dbClient} + steps.s23CorrectGlobalUniqueConstraints = &CorrectGlobalUniqueConstraints{dbClient: dbClient} + steps.s24AddActorToAuthTokens = &AddActorToAuthTokens{dbClient: dbClient} + steps.s25User11AddLowerFieldsToVerifiedEmail = &User11AddLowerFieldsToVerifiedEmail{dbClient: dbClient} + steps.s26AuthUsers3 = &AuthUsers3{dbClient: dbClient} + steps.s27IDPTemplate6SAMLNameIDFormat = &IDPTemplate6SAMLNameIDFormat{dbClient: dbClient} + steps.s28AddFieldTable = &AddFieldTable{dbClient: dbClient} steps.s29FillFieldsForProjectGrant = &FillFieldsForProjectGrant{eventstore: eventstoreClient} steps.s30FillFieldsForOrgDomainVerified = &FillFieldsForOrgDomainVerified{eventstore: eventstoreClient} - steps.s31AddAggregateIndexToFields = &AddAggregateIndexToFields{dbClient: esPusherDBClient} - steps.s32AddAuthSessionID = &AddAuthSessionID{dbClient: esPusherDBClient} - steps.s33SMSConfigs3TwilioAddVerifyServiceSid = &SMSConfigs3TwilioAddVerifyServiceSid{dbClient: esPusherDBClient} - steps.s34AddCacheSchema = &AddCacheSchema{dbClient: queryDBClient} - steps.s35AddPositionToIndexEsWm = &AddPositionToIndexEsWm{dbClient: esPusherDBClient} - steps.s36FillV2Milestones = &FillV3Milestones{dbClient: queryDBClient, eventstore: eventstoreClient} - steps.s37Apps7OIDConfigsBackChannelLogoutURI = &Apps7OIDConfigsBackChannelLogoutURI{dbClient: esPusherDBClient} - steps.s38BackChannelLogoutNotificationStart = &BackChannelLogoutNotificationStart{dbClient: esPusherDBClient, esClient: eventstoreClient} - steps.s40InitPushFunc = &InitPushFunc{dbClient: esPusherDBClient} - steps.s42Apps7OIDCConfigsLoginVersion = &Apps7OIDCConfigsLoginVersion{dbClient: esPusherDBClient} - steps.s43CreateFieldsDomainIndex = &CreateFieldsDomainIndex{dbClient: queryDBClient} - steps.s44ReplaceCurrentSequencesIndex = &ReplaceCurrentSequencesIndex{dbClient: esPusherDBClient} + steps.s31AddAggregateIndexToFields = &AddAggregateIndexToFields{dbClient: dbClient} + steps.s32AddAuthSessionID = &AddAuthSessionID{dbClient: dbClient} + steps.s33SMSConfigs3TwilioAddVerifyServiceSid = &SMSConfigs3TwilioAddVerifyServiceSid{dbClient: dbClient} + steps.s34AddCacheSchema = &AddCacheSchema{dbClient: dbClient} + steps.s35AddPositionToIndexEsWm = &AddPositionToIndexEsWm{dbClient: dbClient} + steps.s36FillV2Milestones = &FillV3Milestones{dbClient: dbClient, eventstore: eventstoreClient} + steps.s37Apps7OIDConfigsBackChannelLogoutURI = &Apps7OIDConfigsBackChannelLogoutURI{dbClient: dbClient} + steps.s38BackChannelLogoutNotificationStart = &BackChannelLogoutNotificationStart{dbClient: dbClient, esClient: eventstoreClient} + steps.s40InitPushFunc = &InitPushFunc{dbClient: dbClient} + steps.s42Apps7OIDCConfigsLoginVersion = &Apps7OIDCConfigsLoginVersion{dbClient: dbClient} + steps.s43CreateFieldsDomainIndex = &CreateFieldsDomainIndex{dbClient: dbClient} + steps.s44ReplaceCurrentSequencesIndex = &ReplaceCurrentSequencesIndex{dbClient: dbClient} steps.s45CorrectProjectOwners = &CorrectProjectOwners{eventstore: eventstoreClient} - steps.s46InitPermissionFunctions = &InitPermissionFunctions{eventstoreClient: esPusherDBClient} + steps.s46InitPermissionFunctions = &InitPermissionFunctions{eventstoreClient: dbClient} - err = projection.Create(ctx, projectionDBClient, eventstoreClient, config.Projections, nil, nil, nil) + err = projection.Create(ctx, dbClient, eventstoreClient, config.Projections, nil, nil, nil) logging.OnError(err).Fatal("unable to start projections") repeatableSteps := []migration.RepeatableMigration{ @@ -264,8 +259,8 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) ctx, eventstoreClient, eventstoreV4, - queryDBClient, - projectionDBClient, + dbClient, + dbClient, masterKey, config, ) diff --git a/cmd/start/start.go b/cmd/start/start.go index db9c9afc54..4091213d2d 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -77,7 +77,6 @@ import ( "github.com/zitadel/zitadel/internal/crypto" cryptoDB "github.com/zitadel/zitadel/internal/crypto/database" "github.com/zitadel/zitadel/internal/database" - "github.com/zitadel/zitadel/internal/database/dialect" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" old_es "github.com/zitadel/zitadel/internal/eventstore/repository/sql" @@ -150,20 +149,12 @@ func startZitadel(ctx context.Context, config *Config, masterKey string, server i18n.MustLoadSupportedLanguagesFromDir() - queryDBClient, err := database.Connect(config.Database, false, dialect.DBPurposeQuery) + dbClient, err := database.Connect(config.Database, false) if err != nil { return fmt.Errorf("cannot start DB client for queries: %w", err) } - esPusherDBClient, err := database.Connect(config.Database, false, dialect.DBPurposeEventPusher) - if err != nil { - return fmt.Errorf("cannot start client for event store pusher: %w", err) - } - projectionDBClient, err := database.Connect(config.Database, false, dialect.DBPurposeProjectionSpooler) - if err != nil { - return fmt.Errorf("cannot start client for projection spooler: %w", err) - } - keyStorage, err := cryptoDB.NewKeyStorage(queryDBClient, masterKey) + keyStorage, err := cryptoDB.NewKeyStorage(dbClient, masterKey) if err != nil { return fmt.Errorf("cannot start key storage: %w", err) } @@ -172,16 +163,16 @@ func startZitadel(ctx context.Context, config *Config, masterKey string, server return err } - config.Eventstore.Pusher = new_es.NewEventstore(esPusherDBClient) - config.Eventstore.Searcher = new_es.NewEventstore(queryDBClient) - config.Eventstore.Querier = old_es.NewCRDB(queryDBClient) + config.Eventstore.Pusher = new_es.NewEventstore(dbClient) + config.Eventstore.Searcher = new_es.NewEventstore(dbClient) + config.Eventstore.Querier = old_es.NewCRDB(dbClient) eventstoreClient := eventstore.NewEventstore(config.Eventstore) - eventstoreV4 := es_v4.NewEventstoreFromOne(es_v4_pg.New(queryDBClient, &es_v4_pg.Config{ + eventstoreV4 := es_v4.NewEventstoreFromOne(es_v4_pg.New(dbClient, &es_v4_pg.Config{ MaxRetries: config.Eventstore.MaxRetries, })) sessionTokenVerifier := internal_authz.SessionTokenVerifier(keys.OIDC) - cacheConnectors, err := connector.StartConnectors(config.Caches, queryDBClient) + cacheConnectors, err := connector.StartConnectors(config.Caches, dbClient) if err != nil { return fmt.Errorf("unable to start caches: %w", err) } @@ -190,8 +181,8 @@ func startZitadel(ctx context.Context, config *Config, masterKey string, server ctx, eventstoreClient, eventstoreV4.Querier, - queryDBClient, - projectionDBClient, + dbClient, + dbClient, cacheConnectors, config.Projections, config.SystemDefaults, @@ -215,7 +206,7 @@ func startZitadel(ctx context.Context, config *Config, masterKey string, server return fmt.Errorf("cannot start queries: %w", err) } - authZRepo, err := authz.Start(queries, eventstoreClient, queryDBClient, keys.OIDC, config.ExternalSecure) + authZRepo, err := authz.Start(queries, eventstoreClient, dbClient, keys.OIDC, config.ExternalSecure) if err != nil { return fmt.Errorf("error starting authz repo: %w", err) } @@ -223,7 +214,7 @@ func startZitadel(ctx context.Context, config *Config, masterKey string, server return internal_authz.CheckPermission(ctx, authZRepo, config.InternalAuthZ.RolePermissionMappings, permission, orgID, resourceID) } - storage, err := config.AssetStorage.NewStorage(queryDBClient.DB) + storage, err := config.AssetStorage.NewStorage(dbClient.DB) if err != nil { return fmt.Errorf("cannot start asset storage client: %w", err) } @@ -268,7 +259,7 @@ func startZitadel(ctx context.Context, config *Config, masterKey string, server if err != nil { return err } - actionsExecutionDBEmitter, err := logstore.NewEmitter[*record.ExecutionLog](ctx, clock, config.Quotas.Execution, execution.NewDatabaseLogStorage(queryDBClient, commands, queries)) + actionsExecutionDBEmitter, err := logstore.NewEmitter[*record.ExecutionLog](ctx, clock, config.Quotas.Execution, execution.NewDatabaseLogStorage(dbClient, commands, queries)) if err != nil { return err } @@ -297,7 +288,7 @@ func startZitadel(ctx context.Context, config *Config, masterKey string, server keys.SMS, keys.OIDC, config.OIDC.DefaultBackChannelLogoutLifetime, - queryDBClient, + dbClient, ) notification.Start(ctx) @@ -313,7 +304,7 @@ func startZitadel(ctx context.Context, config *Config, masterKey string, server commands, queries, eventstoreClient, - queryDBClient, + dbClient, config, storage, authZRepo, @@ -333,7 +324,7 @@ func startZitadel(ctx context.Context, config *Config, masterKey string, server if server != nil { server <- &Server{ Config: config, - DB: queryDBClient, + DB: dbClient, KeyStorage: keyStorage, Keys: keys, Eventstore: eventstoreClient, diff --git a/internal/database/cockroach/crdb.go b/internal/database/cockroach/crdb.go index cc89be8687..48e912b5f5 100644 --- a/internal/database/cockroach/crdb.go +++ b/internal/database/cockroach/crdb.go @@ -3,7 +3,6 @@ package cockroach import ( "context" "database/sql" - "fmt" "strconv" "strings" "time" @@ -14,7 +13,6 @@ import ( "github.com/mitchellh/mapstructure" "github.com/zitadel/logging" - "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/database/dialect" ) @@ -74,19 +72,16 @@ func (_ *Config) Decode(configs []interface{}) (dialect.Connector, error) { return connector, nil } -func (c *Config) Connect(useAdmin bool, pusherRatio, spoolerRatio float64, purpose dialect.DBPurpose) (*sql.DB, *pgxpool.Pool, error) { +func (c *Config) Connect(useAdmin bool) (*sql.DB, *pgxpool.Pool, error) { dialect.RegisterAfterConnect(func(ctx context.Context, c *pgx.Conn) error { // CockroachDB by default does not allow multiple modifications of the same table using ON CONFLICT // This is needed to fill the fields table of the eventstore during eventstore.Push. _, err := c.Exec(ctx, "SET enable_multiple_modifications_of_table = on") return err }) - connConfig, err := dialect.NewConnectionConfig(c.MaxOpenConns, c.MaxIdleConns, pusherRatio, spoolerRatio, purpose) - if err != nil { - return nil, nil, err - } + connConfig := dialect.NewConnectionConfig(c.MaxOpenConns, c.MaxIdleConns) - config, err := pgxpool.ParseConfig(c.String(useAdmin, purpose.AppName())) + config, err := pgxpool.ParseConfig(c.String(useAdmin)) if err != nil { return nil, nil, err } @@ -102,18 +97,6 @@ func (c *Config) Connect(useAdmin bool, pusherRatio, spoolerRatio float64, purpo } } - // For the pusher we set the app name with the instance ID - if purpose == dialect.DBPurposeEventPusher { - config.BeforeAcquire = func(ctx context.Context, conn *pgx.Conn) bool { - return setAppNameWithID(ctx, conn, purpose, authz.GetInstance(ctx).InstanceID()) - } - config.AfterRelease = func(conn *pgx.Conn) bool { - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - return setAppNameWithID(ctx, conn, purpose, "IDLE") - } - } - if connConfig.MaxOpenConns != 0 { config.MaxConns = int32(connConfig.MaxOpenConns) } @@ -195,7 +178,7 @@ func (c *Config) checkSSL(user User) { } } -func (c Config) String(useAdmin bool, appName string) string { +func (c Config) String(useAdmin bool) string { user := c.User if useAdmin { user = c.Admin.User @@ -206,7 +189,7 @@ func (c Config) String(useAdmin bool, appName string) string { "port=" + strconv.Itoa(int(c.Port)), "user=" + user.Username, "dbname=" + c.Database, - "application_name=" + appName, + "application_name=" + dialect.DefaultAppName, "sslmode=" + user.SSL.Mode, } if c.Options != "" { @@ -232,11 +215,3 @@ func (c Config) String(useAdmin bool, appName string) string { return strings.Join(fields, " ") } - -func setAppNameWithID(ctx context.Context, conn *pgx.Conn, purpose dialect.DBPurpose, id string) bool { - // needs to be set like this because psql complains about parameters in the SET statement - query := fmt.Sprintf("SET application_name = '%s_%s'", purpose.AppName(), id) - _, err := conn.Exec(ctx, query) - logging.OnError(err).Warn("failed to set application name") - return err == nil -} diff --git a/internal/database/database.go b/internal/database/database.go index b86a9f247c..e254edadc1 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -65,10 +65,8 @@ func CloseTransaction(tx Tx, err error) error { } type Config struct { - Dialects map[string]interface{} `mapstructure:",remain"` - EventPushConnRatio float64 - ProjectionSpoolerConnRatio float64 - connector dialect.Connector + Dialects map[string]interface{} `mapstructure:",remain"` + connector dialect.Connector } func (c *Config) SetConnector(connector dialect.Connector) { @@ -134,8 +132,8 @@ func QueryJSONObject[T any](ctx context.Context, db *DB, query string, args ...a return obj, nil } -func Connect(config Config, useAdmin bool, purpose dialect.DBPurpose) (*DB, error) { - client, pool, err := config.connector.Connect(useAdmin, config.EventPushConnRatio, config.ProjectionSpoolerConnRatio, purpose) +func Connect(config Config, useAdmin bool) (*DB, error) { + client, pool, err := config.connector.Connect(useAdmin) if err != nil { return nil, err } diff --git a/internal/database/dialect/config.go b/internal/database/dialect/config.go index 8ca4e7f748..71fb477ea1 100644 --- a/internal/database/dialect/config.go +++ b/internal/database/dialect/config.go @@ -26,36 +26,11 @@ type Matcher interface { } const ( - QueryAppName = "zitadel_queries" - EventstorePusherAppName = "zitadel_es_pusher" - ProjectionSpoolerAppName = "zitadel_projection_spooler" - defaultAppName = "zitadel" + DefaultAppName = "zitadel" ) -// DBPurpose is what the resulting connection pool is used for. -type DBPurpose int - -const ( - DBPurposeQuery DBPurpose = iota - DBPurposeEventPusher - DBPurposeProjectionSpooler -) - -func (p DBPurpose) AppName() string { - switch p { - case DBPurposeQuery: - return QueryAppName - case DBPurposeEventPusher: - return EventstorePusherAppName - case DBPurposeProjectionSpooler: - return ProjectionSpoolerAppName - default: - return defaultAppName - } -} - type Connector interface { - Connect(useAdmin bool, pusherRatio, spoolerRatio float64, purpose DBPurpose) (*sql.DB, *pgxpool.Pool, error) + Connect(useAdmin bool) (*sql.DB, *pgxpool.Pool, error) Password() string Database } diff --git a/internal/database/dialect/config_test.go b/internal/database/dialect/config_test.go deleted file mode 100644 index d7297f8b67..0000000000 --- a/internal/database/dialect/config_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package dialect - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestDBPurpose_AppName(t *testing.T) { - tests := []struct { - p DBPurpose - want string - }{ - { - p: DBPurposeQuery, - want: QueryAppName, - }, - { - p: DBPurposeEventPusher, - want: EventstorePusherAppName, - }, - { - p: DBPurposeProjectionSpooler, - want: ProjectionSpoolerAppName, - }, - { - p: 99, - want: defaultAppName, - }, - } - for _, tt := range tests { - t.Run(tt.want, func(t *testing.T) { - assert.Equal(t, tt.want, tt.p.AppName()) - }) - } -} diff --git a/internal/database/dialect/connections.go b/internal/database/dialect/connections.go index f957870df0..13a4d657c3 100644 --- a/internal/database/dialect/connections.go +++ b/internal/database/dialect/connections.go @@ -3,7 +3,6 @@ package dialect import ( "context" "errors" - "fmt" "reflect" "github.com/jackc/pgx/v5" @@ -11,11 +10,8 @@ import ( ) var ( - ErrNegativeRatio = errors.New("ratio cannot be negative") - ErrHighSumRatio = errors.New("sum of pusher and projection ratios must be < 1") ErrIllegalMaxOpenConns = errors.New("MaxOpenConns of the database must be higher than 3 or 0 for unlimited") ErrIllegalMaxIdleConns = errors.New("MaxIdleConns of the database must be higher than 3 or 0 for unlimited") - ErrInvalidPurpose = errors.New("DBPurpose out of range") ) // ConnectionConfig defines the Max Open and Idle connections for a DB connection pool. @@ -25,28 +21,6 @@ type ConnectionConfig struct { AfterConnect []func(ctx context.Context, c *pgx.Conn) error } -// takeRatio of MaxOpenConns and MaxIdleConns from config and returns -// a new ConnectionConfig with the resulting values. -func (c *ConnectionConfig) takeRatio(ratio float64) (*ConnectionConfig, error) { - if ratio < 0 { - return nil, ErrNegativeRatio - } - - out := &ConnectionConfig{ - MaxOpenConns: uint32(ratio * float64(c.MaxOpenConns)), - MaxIdleConns: uint32(ratio * float64(c.MaxIdleConns)), - AfterConnect: c.AfterConnect, - } - if c.MaxOpenConns != 0 && out.MaxOpenConns < 1 && ratio > 0 { - out.MaxOpenConns = 1 - } - if c.MaxIdleConns != 0 && out.MaxIdleConns < 1 && ratio > 0 { - out.MaxIdleConns = 1 - } - - return out, nil -} - var afterConnectFuncs []func(ctx context.Context, c *pgx.Conn) error func RegisterAfterConnect(f func(ctx context.Context, c *pgx.Conn) error) { @@ -82,48 +56,10 @@ func RegisterDefaultPgTypeVariants[T any](m *pgtype.Map, name, arrayName string) // // openConns and idleConns must be at least 3 or 0, which means no limit. // The pusherRatio and spoolerRatio must be between 0 and 1. -func NewConnectionConfig(openConns, idleConns uint32, pusherRatio, projectionRatio float64, purpose DBPurpose) (*ConnectionConfig, error) { - if openConns != 0 && openConns < 3 { - return nil, ErrIllegalMaxOpenConns - } - if idleConns != 0 && idleConns < 3 { - return nil, ErrIllegalMaxIdleConns - } - if pusherRatio+projectionRatio >= 1 { - return nil, ErrHighSumRatio - } - - queryConfig := &ConnectionConfig{ +func NewConnectionConfig(openConns, idleConns uint32) *ConnectionConfig { + return &ConnectionConfig{ MaxOpenConns: openConns, MaxIdleConns: idleConns, AfterConnect: afterConnectFuncs, } - pusherConfig, err := queryConfig.takeRatio(pusherRatio) - if err != nil { - return nil, fmt.Errorf("event pusher: %w", err) - } - - spoolerConfig, err := queryConfig.takeRatio(projectionRatio) - if err != nil { - return nil, fmt.Errorf("projection spooler: %w", err) - } - - // subtract the claimed amount - if queryConfig.MaxOpenConns > 0 { - queryConfig.MaxOpenConns -= pusherConfig.MaxOpenConns + spoolerConfig.MaxOpenConns - } - if queryConfig.MaxIdleConns > 0 { - queryConfig.MaxIdleConns -= pusherConfig.MaxIdleConns + spoolerConfig.MaxIdleConns - } - - switch purpose { - case DBPurposeQuery: - return queryConfig, nil - case DBPurposeEventPusher: - return pusherConfig, nil - case DBPurposeProjectionSpooler: - return spoolerConfig, nil - default: - return nil, fmt.Errorf("%w: %v", ErrInvalidPurpose, purpose) - } } diff --git a/internal/database/dialect/connections_test.go b/internal/database/dialect/connections_test.go deleted file mode 100644 index 6256658d0a..0000000000 --- a/internal/database/dialect/connections_test.go +++ /dev/null @@ -1,252 +0,0 @@ -package dialect - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestConnectionConfig_takeRatio(t *testing.T) { - type fields struct { - MaxOpenConns uint32 - MaxIdleConns uint32 - } - tests := []struct { - name string - fields fields - ratio float64 - wantOut *ConnectionConfig - wantErr error - }{ - { - name: "ratio less than 0 error", - ratio: -0.1, - wantErr: ErrNegativeRatio, - }, - { - name: "zero values", - fields: fields{ - MaxOpenConns: 0, - MaxIdleConns: 0, - }, - ratio: 0, - wantOut: &ConnectionConfig{ - MaxOpenConns: 0, - MaxIdleConns: 0, - }, - }, - { - name: "max conns, ratio 0", - fields: fields{ - MaxOpenConns: 10, - MaxIdleConns: 5, - }, - ratio: 0, - wantOut: &ConnectionConfig{ - MaxOpenConns: 0, - MaxIdleConns: 0, - }, - }, - { - name: "half ratio", - fields: fields{ - MaxOpenConns: 10, - MaxIdleConns: 5, - }, - ratio: 0.5, - wantOut: &ConnectionConfig{ - MaxOpenConns: 5, - MaxIdleConns: 2, - }, - }, - { - name: "minimal 1", - fields: fields{ - MaxOpenConns: 2, - MaxIdleConns: 2, - }, - ratio: 0.1, - wantOut: &ConnectionConfig{ - MaxOpenConns: 1, - MaxIdleConns: 1, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - in := &ConnectionConfig{ - MaxOpenConns: tt.fields.MaxOpenConns, - MaxIdleConns: tt.fields.MaxIdleConns, - } - got, err := in.takeRatio(tt.ratio) - require.ErrorIs(t, err, tt.wantErr) - assert.Equal(t, tt.wantOut, got) - }) - } -} - -func TestNewConnectionConfig(t *testing.T) { - type args struct { - openConns uint32 - idleConns uint32 - pusherRatio float64 - projectionRatio float64 - purpose DBPurpose - } - tests := []struct { - name string - args args - want *ConnectionConfig - wantErr error - }{ - { - name: "illegal open conns error", - args: args{ - openConns: 2, - idleConns: 3, - }, - wantErr: ErrIllegalMaxOpenConns, - }, - { - name: "illegal idle conns error", - args: args{ - openConns: 3, - idleConns: 2, - }, - wantErr: ErrIllegalMaxIdleConns, - }, - { - name: "high ration sum error", - args: args{ - openConns: 3, - idleConns: 3, - pusherRatio: 0.5, - projectionRatio: 0.5, - }, - wantErr: ErrHighSumRatio, - }, - { - name: "illegal pusher ratio error", - args: args{ - openConns: 3, - idleConns: 3, - pusherRatio: -0.1, - projectionRatio: 0.5, - }, - wantErr: ErrNegativeRatio, - }, - { - name: "illegal projection ratio error", - args: args{ - openConns: 3, - idleConns: 3, - pusherRatio: 0.5, - projectionRatio: -0.1, - }, - wantErr: ErrNegativeRatio, - }, - { - name: "invalid purpose error", - args: args{ - openConns: 3, - idleConns: 3, - pusherRatio: 0.4, - projectionRatio: 0.4, - purpose: 99, - }, - wantErr: ErrInvalidPurpose, - }, - { - name: "min values, query purpose", - args: args{ - openConns: 3, - idleConns: 3, - pusherRatio: 0.2, - projectionRatio: 0.2, - purpose: DBPurposeQuery, - }, - want: &ConnectionConfig{ - MaxOpenConns: 1, - MaxIdleConns: 1, - }, - }, - { - name: "min values, pusher purpose", - args: args{ - openConns: 3, - idleConns: 3, - pusherRatio: 0.2, - projectionRatio: 0.2, - purpose: DBPurposeEventPusher, - }, - want: &ConnectionConfig{ - MaxOpenConns: 1, - MaxIdleConns: 1, - }, - }, - { - name: "min values, projection purpose", - args: args{ - openConns: 3, - idleConns: 3, - pusherRatio: 0.2, - projectionRatio: 0.2, - purpose: DBPurposeProjectionSpooler, - }, - want: &ConnectionConfig{ - MaxOpenConns: 1, - MaxIdleConns: 1, - }, - }, - { - name: "high values, query purpose", - args: args{ - openConns: 10, - idleConns: 5, - pusherRatio: 0.2, - projectionRatio: 0.2, - purpose: DBPurposeQuery, - }, - want: &ConnectionConfig{ - MaxOpenConns: 6, - MaxIdleConns: 3, - }, - }, - { - name: "high values, pusher purpose", - args: args{ - openConns: 10, - idleConns: 5, - pusherRatio: 0.2, - projectionRatio: 0.2, - purpose: DBPurposeEventPusher, - }, - want: &ConnectionConfig{ - MaxOpenConns: 2, - MaxIdleConns: 1, - }, - }, - { - name: "high values, projection purpose", - args: args{ - openConns: 10, - idleConns: 5, - pusherRatio: 0.2, - projectionRatio: 0.2, - purpose: DBPurposeProjectionSpooler, - }, - want: &ConnectionConfig{ - MaxOpenConns: 2, - MaxIdleConns: 1, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := NewConnectionConfig(tt.args.openConns, tt.args.idleConns, tt.args.pusherRatio, tt.args.projectionRatio, tt.args.purpose) - require.ErrorIs(t, err, tt.wantErr) - assert.Equal(t, tt.want, got) - }) - } -} diff --git a/internal/database/postgres/pg.go b/internal/database/postgres/pg.go index c12e122437..5f4d9a6c9b 100644 --- a/internal/database/postgres/pg.go +++ b/internal/database/postgres/pg.go @@ -3,7 +3,6 @@ package postgres import ( "context" "database/sql" - "fmt" "strconv" "strings" "time" @@ -14,7 +13,6 @@ import ( "github.com/mitchellh/mapstructure" "github.com/zitadel/logging" - "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/database/dialect" ) @@ -75,13 +73,10 @@ func (_ *Config) Decode(configs []interface{}) (dialect.Connector, error) { return connector, nil } -func (c *Config) Connect(useAdmin bool, pusherRatio, spoolerRatio float64, purpose dialect.DBPurpose) (*sql.DB, *pgxpool.Pool, error) { - connConfig, err := dialect.NewConnectionConfig(c.MaxOpenConns, c.MaxIdleConns, pusherRatio, spoolerRatio, purpose) - if err != nil { - return nil, nil, err - } +func (c *Config) Connect(useAdmin bool) (*sql.DB, *pgxpool.Pool, error) { + connConfig := dialect.NewConnectionConfig(c.MaxOpenConns, c.MaxIdleConns) - config, err := pgxpool.ParseConfig(c.String(useAdmin, purpose.AppName())) + config, err := pgxpool.ParseConfig(c.String(useAdmin)) if err != nil { return nil, nil, err } @@ -95,18 +90,6 @@ func (c *Config) Connect(useAdmin bool, pusherRatio, spoolerRatio float64, purpo return nil } - // For the pusher we set the app name with the instance ID - if purpose == dialect.DBPurposeEventPusher { - config.BeforeAcquire = func(ctx context.Context, conn *pgx.Conn) bool { - return setAppNameWithID(ctx, conn, purpose, authz.GetInstance(ctx).InstanceID()) - } - config.AfterRelease = func(conn *pgx.Conn) bool { - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - return setAppNameWithID(ctx, conn, purpose, "IDLE") - } - } - if connConfig.MaxOpenConns != 0 { config.MaxConns = int32(connConfig.MaxOpenConns) } @@ -191,7 +174,7 @@ func (s *Config) checkSSL(user User) { } } -func (c Config) String(useAdmin bool, appName string) string { +func (c Config) String(useAdmin bool) string { user := c.User if useAdmin { user = c.Admin.User @@ -201,7 +184,7 @@ func (c Config) String(useAdmin bool, appName string) string { "host=" + c.Host, "port=" + strconv.Itoa(int(c.Port)), "user=" + user.Username, - "application_name=" + appName, + "application_name=" + dialect.DefaultAppName, "sslmode=" + user.SSL.Mode, } if c.Options != "" { @@ -233,11 +216,3 @@ func (c Config) String(useAdmin bool, appName string) string { return strings.Join(fields, " ") } - -func setAppNameWithID(ctx context.Context, conn *pgx.Conn, purpose dialect.DBPurpose, id string) bool { - // needs to be set like this because psql complains about parameters in the SET statement - query := fmt.Sprintf("SET application_name = '%s_%s'", purpose.AppName(), id) - _, err := conn.Exec(ctx, query) - logging.OnError(err).Warn("failed to set application name") - return err == nil -} diff --git a/internal/eventstore/repository/sql/query.go b/internal/eventstore/repository/sql/query.go index b93e663b17..4e1cc87aff 100644 --- a/internal/eventstore/repository/sql/query.go +++ b/internal/eventstore/repository/sql/query.go @@ -309,7 +309,7 @@ func prepareConditions(criteria querier, query *repository.SearchQuery, useV1 bo } for i := range instanceIDs { - instanceIDs[i] = dialect.DBPurposeEventPusher.AppName() + "_" + instanceIDs[i] + instanceIDs[i] = "zitadel_es_pusher_" + instanceIDs[i] } clauses += awaitOpenTransactions(useV1) diff --git a/internal/eventstore/v3/push.go b/internal/eventstore/v3/push.go index fb597021e2..6497b96ed8 100644 --- a/internal/eventstore/v3/push.go +++ b/internal/eventstore/v3/push.go @@ -4,9 +4,11 @@ import ( "context" "database/sql" _ "embed" + "fmt" "github.com/zitadel/logging" + "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/database" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/telemetry/tracing" @@ -55,6 +57,11 @@ func (es *Eventstore) writeCommands(ctx context.Context, client database.Context }() } + _, err = tx.ExecContext(ctx, fmt.Sprintf("SET LOCAL application_name = '%s'", fmt.Sprintf("zitadel_es_pusher_%s", authz.GetInstance(ctx).InstanceID()))) + if err != nil { + return nil, err + } + events, err := writeEvents(ctx, tx, commands) if err != nil { return nil, err From 69372e52091634d6654130fd99fabdd9d8f0b61d Mon Sep 17 00:00:00 2001 From: Stefan Benz <46600784+stebenz@users.noreply.github.com> Date: Thu, 16 Jan 2025 16:05:55 +0100 Subject: [PATCH 27/30] fix: case changes on org domain (#9196) # Which Problems Are Solved Organization name change results in domain events even if the domain itself doesn't change. # How the Problems Are Solved Check if the domain itself really changes, and if not, don't create the events. # Additional Changes Unittest for this specific case. # Additional Context None --- internal/command/org_domain.go | 4 +++ internal/command/org_test.go | 48 ++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/internal/command/org_domain.go b/internal/command/org_domain.go index 929e0ab75b..2e132f6c47 100644 --- a/internal/command/org_domain.go +++ b/internal/command/org_domain.go @@ -334,6 +334,10 @@ func (c *Commands) changeDefaultDomain(ctx context.Context, orgID, newName strin if err != nil { return nil, err } + // rename of organization resulting in no change in the domain + if newDefaultDomain == defaultDomain { + return nil, nil + } events := []eventstore.Command{ org.NewDomainAddedEvent(ctx, orgAgg, newDefaultDomain), org.NewDomainVerifiedEvent(ctx, orgAgg, newDefaultDomain), diff --git a/internal/command/org_test.go b/internal/command/org_test.go index cc6a384f21..bf88b55a86 100644 --- a/internal/command/org_test.go +++ b/internal/command/org_test.go @@ -702,6 +702,54 @@ func TestCommandSide_ChangeOrg(t *testing.T) { }, res: res{}, }, + { + name: "change org name case verified, with primary", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + org.NewOrgAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "org"), + ), + ), + expectFilter( + eventFromEventPusher( + org.NewOrgAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "org"), + ), + eventFromEventPusher( + org.NewDomainAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "org.zitadel.ch"), + ), + eventFromEventPusher( + org.NewDomainVerifiedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "org.zitadel.ch"), + ), + eventFromEventPusher( + org.NewDomainPrimarySetEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "org.zitadel.ch"), + ), + ), + expectPush( + org.NewOrgChangedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, "org", "ORG", + ), + ), + ), + }, + args: args{ + ctx: http_util.WithRequestedHost(context.Background(), "zitadel.ch"), + orgID: "org1", + name: "ORG", + }, + res: res{}, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { From 60857c8d3e0108437a8a8eb5cc4efae5e440af85 Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Fri, 17 Jan 2025 08:42:14 +0100 Subject: [PATCH 28/30] fix: cancel notifications on missing channels and configurable (twilio) error codes (#9185) # Which Problems Are Solved If a notification channel was not present, notification workers would retry to the max attempts. This leads to unnecessary load. Additionally, a client noticed bad actors trying to abuse SMS MFA. # How the Problems Are Solved - Directly cancel the notification on: - a missing channel and stop retries. - any `4xx` errors from Twilio Verify # Additional Changes None # Additional Context reported by customer --- .../notification/channels/twilio/channel.go | 44 ++++++++++++++++--- internal/notification/types/user_email.go | 9 +++- internal/notification/types/user_phone.go | 9 +++- 3 files changed, 52 insertions(+), 10 deletions(-) diff --git a/internal/notification/channels/twilio/channel.go b/internal/notification/channels/twilio/channel.go index 8b7f0e24f2..e13f45e00b 100644 --- a/internal/notification/channels/twilio/channel.go +++ b/internal/notification/channels/twilio/channel.go @@ -9,11 +9,16 @@ import ( verify "github.com/twilio/twilio-go/rest/verify/v2" "github.com/zitadel/logging" + "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/notification/channels" "github.com/zitadel/zitadel/internal/notification/messages" "github.com/zitadel/zitadel/internal/zerrors" ) +const ( + aggregateTypeNotification = "notification" +) + func InitChannel(config Config) channels.NotificationChannel { client := twilio.NewRestClientWithParams(twilio.ClientParams{Username: config.SID, Password: config.Token}) logging.Debug("successfully initialized twilio sms channel") @@ -30,13 +35,19 @@ func InitChannel(config Config) channels.NotificationChannel { resp, err := client.VerifyV2.CreateVerification(config.VerifyServiceSID, params) + // In case of any client error (4xx), we should not retry sending the verification code + // as it would be a waste of resources and could potentially result in a rate limit. var twilioErr *twilioClient.TwilioRestError - if errors.As(err, &twilioErr) && twilioErr.Code == 60203 { - // If there were too many attempts to send a verification code (more than 5 times) - // without a verification check, even retries with backoff might not solve the problem. - // Instead, let the user initiate the verification again (e.g. using "resend code") - // https://www.twilio.com/docs/api/errors/60203 - logging.WithFields("error", twilioErr.Message, "code", twilioErr.Code).Warn("twilio create verification error") + if errors.As(err, &twilioErr) && twilioErr.Status >= 400 && twilioErr.Status < 500 { + userID, notificationID := userAndNotificationIDsFromEvent(twilioMsg.TriggeringEvent) + logging.WithFields( + "error", twilioErr.Message, + "status", twilioErr.Status, + "code", twilioErr.Code, + "instanceID", twilioMsg.TriggeringEvent.Aggregate().InstanceID, + "userID", userID, + "notificationID", notificationID). + Warn("twilio create verification error") return channels.NewCancelError(twilioErr) } @@ -65,3 +76,24 @@ func InitChannel(config Config) channels.NotificationChannel { return nil }) } + +func userAndNotificationIDsFromEvent(event eventstore.Event) (userID, notificationID string) { + aggID := event.Aggregate().ID + + // we cannot cast to the actual event type because of circular dependencies + // so we just check the type... + if event.Aggregate().Type != aggregateTypeNotification { + // in case it's not a notification event, we can directly return the aggregate ID (as it's a user event) + return aggID, "" + } + // ...and unmarshal the event data from the notification event into a struct that contains the fields we need + var data struct { + Request struct { + UserID string `json:"userID"` + } `json:"request"` + } + if err := event.Unmarshal(&data); err != nil { + return "", aggID + } + return data.Request.UserID, aggID +} diff --git a/internal/notification/types/user_email.go b/internal/notification/types/user_email.go index 985fe81391..d32ee868f0 100644 --- a/internal/notification/types/user_email.go +++ b/internal/notification/types/user_email.go @@ -8,6 +8,7 @@ import ( "github.com/zitadel/logging" "github.com/zitadel/zitadel/internal/eventstore" + zchannels "github.com/zitadel/zitadel/internal/notification/channels" "github.com/zitadel/zitadel/internal/notification/messages" "github.com/zitadel/zitadel/internal/notification/templates" "github.com/zitadel/zitadel/internal/query" @@ -27,7 +28,9 @@ func generateEmail( emailChannels, config, err := channels.Email(ctx) logging.OnError(err).Error("could not create email channel") if emailChannels == nil || emailChannels.Len() == 0 { - return zerrors.ThrowPreconditionFailed(nil, "PHONE-w8nfow", "Errors.Notification.Channels.NotPresent") + return zchannels.NewCancelError( + zerrors.ThrowPreconditionFailed(nil, "MAIL-w8nfow", "Errors.Notification.Channels.NotPresent"), + ) } recipient := user.VerifiedEmail if lastEmail { @@ -67,7 +70,9 @@ func generateEmail( } return webhookChannels.HandleMessage(message) } - return zerrors.ThrowPreconditionFailed(nil, "MAIL-83nof", "Errors.Notification.Channels.NotPresent") + return zchannels.NewCancelError( + zerrors.ThrowPreconditionFailed(nil, "MAIL-83nof", "Errors.Notification.Channels.NotPresent"), + ) } func mapNotifyUserToArgs(user *query.NotifyUser, args map[string]interface{}) map[string]interface{} { diff --git a/internal/notification/types/user_phone.go b/internal/notification/types/user_phone.go index 0016f0f7a4..3ee202dfab 100644 --- a/internal/notification/types/user_phone.go +++ b/internal/notification/types/user_phone.go @@ -7,6 +7,7 @@ import ( "github.com/zitadel/logging" "github.com/zitadel/zitadel/internal/eventstore" + zchannels "github.com/zitadel/zitadel/internal/notification/channels" "github.com/zitadel/zitadel/internal/notification/messages" "github.com/zitadel/zitadel/internal/notification/senders" "github.com/zitadel/zitadel/internal/notification/templates" @@ -33,7 +34,9 @@ func generateSms( smsChannels, config, err := channels.SMS(ctx) logging.OnError(err).Error("could not create sms channel") if smsChannels == nil || smsChannels.Len() == 0 { - return zerrors.ThrowPreconditionFailed(nil, "PHONE-w8nfow", "Errors.Notification.Channels.NotPresent") + return zchannels.NewCancelError( + zerrors.ThrowPreconditionFailed(nil, "PHONE-w8nfow", "Errors.Notification.Channels.NotPresent"), + ) } recipient := user.VerifiedPhone if lastPhone { @@ -85,5 +88,7 @@ func generateSms( } return webhookChannels.HandleMessage(message) } - return zerrors.ThrowPreconditionFailed(nil, "PHONE-w8nfow", "Errors.Notification.Channels.NotPresent") + return zchannels.NewCancelError( + zerrors.ThrowPreconditionFailed(nil, "PHONE-83nof", "Errors.Notification.Channels.NotPresent"), + ) } From 9532c9bea5ed6316dbf885dfb6f847f093788f7e Mon Sep 17 00:00:00 2001 From: Silvan <27845747+adlerhurst@users.noreply.github.com> Date: Fri, 17 Jan 2025 15:32:05 +0100 Subject: [PATCH 29/30] fix(eventstore): correct sql push function (#9201) # Which Problems Are Solved https://github.com/zitadel/zitadel/pull/9186 introduced the new `push` sql function for cockroachdb. The function used the wrong database function to generate the position of the event and would therefore insert events at a position before events created with an old Zitadel version. # How the Problems Are Solved Instead of `EXTRACT(EPOCH FROM NOW())`, `cluster_logical_timestamp()` is used to calculate the position of an event. # Additional Context - Introduced in https://github.com/zitadel/zitadel/pull/9186 - Affected versions: https://github.com/zitadel/zitadel/releases/tag/v2.67.3 --- cmd/setup/40.go | 2 +- cmd/setup/40/cockroach/40_init_push_func.sql | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/setup/40.go b/cmd/setup/40.go index 39191a9b8d..ff1188776f 100644 --- a/cmd/setup/40.go +++ b/cmd/setup/40.go @@ -48,5 +48,5 @@ func (mig *InitPushFunc) Execute(ctx context.Context, _ eventstore.Event) (err e } func (mig *InitPushFunc) String() string { - return "40_init_push_func_v3" + return "40_init_push_func_v4" } diff --git a/cmd/setup/40/cockroach/40_init_push_func.sql b/cmd/setup/40/cockroach/40_init_push_func.sql index 802dc759c9..9a08b5d355 100644 --- a/cmd/setup/40/cockroach/40_init_push_func.sql +++ b/cmd/setup/40/cockroach/40_init_push_func.sql @@ -97,7 +97,7 @@ BEGIN , ("c").payload , ("c").creator , COALESCE(current_owner, ("c").owner) -- AS owner - , EXTRACT(EPOCH FROM NOW()) -- AS position + , cluster_logical_timestamp() -- AS position , ordinality::INT -- AS in_tx_order FROM UNNEST(commands) WITH ORDINALITY AS c From 94cbf97534d3712c7223208160b900c6733b096b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Fri, 17 Jan 2025 16:16:26 +0100 Subject: [PATCH 30/30] fix(permissions_v2): add membership fields migration (#9199) # Which Problems Are Solved Memberships did not have a fields table fill migration. # How the Problems Are Solved Add filling of membership fields to the repeatable steps. # Additional Changes - Use the same repeatable step for multiple fill fields handlers. - Fix an error for PostgreSQL 15 where a subquery in a `FROM` clause needs an alias ing the `permitted_orgs` function. # Additional Context - Part of https://github.com/zitadel/zitadel/issues/9188 - Introduced in https://github.com/zitadel/zitadel/pull/9152 --- cmd/setup/41.go | 44 ---------------- cmd/setup/46/06-permitted_orgs_function.sql | 2 +- cmd/setup/fill_fields.go | 51 +++++++++++++++++++ cmd/setup/setup.go | 7 ++- go.mod | 4 +- .../user/v2/integration_test/user_test.go | 3 +- .../api/scim/integration_test/scim_test.go | 3 +- .../integration_test/users_create_test.go | 14 ++--- .../integration_test/users_delete_test.go | 16 +++--- .../scim/integration_test/users_get_test.go | 12 +++-- .../integration_test/users_replace_test.go | 12 +++-- internal/command/instance.go | 2 +- internal/command/milestone.go | 1 + internal/command/project_member_model.go | 8 +-- .../eventstore/handler/v2/field_handler.go | 4 ++ internal/eventstore/v3/event.go | 1 + internal/query/projection/eventstore_field.go | 31 +++++++++++ internal/query/projection/project_member.go | 16 +++--- .../query/projection/project_member_test.go | 12 ++--- internal/query/projection/projection.go | 2 + internal/repository/project/eventstore.go | 8 +-- internal/repository/project/member.go | 16 +++--- 22 files changed, 164 insertions(+), 105 deletions(-) delete mode 100644 cmd/setup/41.go create mode 100644 cmd/setup/fill_fields.go diff --git a/cmd/setup/41.go b/cmd/setup/41.go deleted file mode 100644 index fa4a1d5a4b..0000000000 --- a/cmd/setup/41.go +++ /dev/null @@ -1,44 +0,0 @@ -package setup - -import ( - "context" - - "github.com/zitadel/zitadel/internal/api/authz" - "github.com/zitadel/zitadel/internal/eventstore" - "github.com/zitadel/zitadel/internal/query/projection" - "github.com/zitadel/zitadel/internal/repository/instance" -) - -type FillFieldsForInstanceDomains struct { - eventstore *eventstore.Eventstore -} - -func (mig *FillFieldsForInstanceDomains) Execute(ctx context.Context, _ eventstore.Event) error { - instances, err := mig.eventstore.InstanceIDs( - ctx, - eventstore.NewSearchQueryBuilder(eventstore.ColumnsInstanceIDs). - OrderDesc(). - AddQuery(). - AggregateTypes("instance"). - EventTypes(instance.InstanceAddedEventType). - Builder(), - ) - if err != nil { - return err - } - for _, instance := range instances { - ctx := authz.WithInstanceID(ctx, instance) - if err := projection.InstanceDomainFields.Trigger(ctx); err != nil { - return err - } - } - return nil -} - -func (mig *FillFieldsForInstanceDomains) String() string { - return "repeatable_fill_fields_for_instance_domains" -} - -func (f *FillFieldsForInstanceDomains) Check(lastRun map[string]interface{}) bool { - return true -} diff --git a/cmd/setup/46/06-permitted_orgs_function.sql b/cmd/setup/46/06-permitted_orgs_function.sql index 55d63c1a19..0c8c0fc673 100644 --- a/cmd/setup/46/06-permitted_orgs_function.sql +++ b/cmd/setup/46/06-permitted_orgs_function.sql @@ -44,7 +44,7 @@ BEGIN WHERE om.role = ANY(matched_roles) AND om.instance_id = instanceID AND om.user_id = userId - ); + ) AS orgs; RETURN; END; $$; diff --git a/cmd/setup/fill_fields.go b/cmd/setup/fill_fields.go new file mode 100644 index 0000000000..9dbb2fed7e --- /dev/null +++ b/cmd/setup/fill_fields.go @@ -0,0 +1,51 @@ +package setup + +import ( + "context" + "fmt" + + "github.com/zitadel/logging" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/eventstore/handler/v2" + "github.com/zitadel/zitadel/internal/repository/instance" +) + +type RepeatableFillFields struct { + eventstore *eventstore.Eventstore + handlers []*handler.FieldHandler +} + +func (mig *RepeatableFillFields) Execute(ctx context.Context, _ eventstore.Event) error { + instances, err := mig.eventstore.InstanceIDs( + ctx, + eventstore.NewSearchQueryBuilder(eventstore.ColumnsInstanceIDs). + OrderDesc(). + AddQuery(). + AggregateTypes(instance.AggregateType). + EventTypes(instance.InstanceAddedEventType). + Builder(), + ) + if err != nil { + return err + } + for _, instance := range instances { + ctx := authz.WithInstanceID(ctx, instance) + for _, handler := range mig.handlers { + logging.WithFields("migration", mig.String(), "instance_id", instance, "handler", handler.String()).Info("run fields trigger") + if err := handler.Trigger(ctx); err != nil { + return fmt.Errorf("%s: %s: %w", mig.String(), handler.String(), err) + } + } + } + return nil +} + +func (mig *RepeatableFillFields) String() string { + return "repeatable_fill_fields" +} + +func (f *RepeatableFillFields) Check(lastRun map[string]interface{}) bool { + return true +} diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index cd9d3d9673..a48b74acb8 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -28,6 +28,7 @@ import ( "github.com/zitadel/zitadel/internal/database" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/eventstore/handler/v2" old_es "github.com/zitadel/zitadel/internal/eventstore/repository/sql" new_es "github.com/zitadel/zitadel/internal/eventstore/v3" "github.com/zitadel/zitadel/internal/i18n" @@ -189,8 +190,12 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) &DeleteStaleOrgFields{ eventstore: eventstoreClient, }, - &FillFieldsForInstanceDomains{ + &RepeatableFillFields{ eventstore: eventstoreClient, + handlers: []*handler.FieldHandler{ + projection.InstanceDomainFields, + projection.MembershipFields, + }, }, &SyncRolePermissions{ eventstore: eventstoreClient, diff --git a/go.mod b/go.mod index aa9fbb64a2..20d7322124 100644 --- a/go.mod +++ b/go.mod @@ -28,6 +28,8 @@ require ( github.com/go-jose/go-jose/v4 v4.0.4 github.com/go-ldap/ldap/v3 v3.4.8 github.com/go-webauthn/webauthn v0.10.2 + github.com/goccy/go-json v0.10.3 + github.com/golang/protobuf v1.5.4 github.com/gorilla/csrf v1.7.2 github.com/gorilla/mux v1.8.1 github.com/gorilla/schema v1.4.1 @@ -106,11 +108,9 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/go-sql-driver/mysql v1.7.1 // indirect github.com/go-webauthn/x v0.1.9 // indirect - github.com/goccy/go-json v0.10.3 // indirect github.com/golang-jwt/jwt/v4 v4.5.1 // indirect github.com/golang-jwt/jwt/v5 v5.2.1 // indirect github.com/golang/mock v1.6.0 // indirect - github.com/golang/protobuf v1.5.4 // indirect github.com/google/go-tpm v0.9.0 // indirect github.com/google/pprof v0.0.0-20240528025155-186aa0362fba // indirect github.com/google/s2a-go v0.1.7 // indirect diff --git a/internal/api/grpc/user/v2/integration_test/user_test.go b/internal/api/grpc/user/v2/integration_test/user_test.go index 8d4c254c6b..1d6d12241a 100644 --- a/internal/api/grpc/user/v2/integration_test/user_test.go +++ b/internal/api/grpc/user/v2/integration_test/user_test.go @@ -10,12 +10,11 @@ import ( "testing" "time" - "github.com/zitadel/logging" - "github.com/brianvoe/gofakeit/v6" "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/zitadel/logging" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/timestamppb" diff --git a/internal/api/scim/integration_test/scim_test.go b/internal/api/scim/integration_test/scim_test.go index e722ffdb18..84c4d96bec 100644 --- a/internal/api/scim/integration_test/scim_test.go +++ b/internal/api/scim/integration_test/scim_test.go @@ -4,10 +4,11 @@ package integration_test import ( "context" - "github.com/zitadel/zitadel/internal/integration" "os" "testing" "time" + + "github.com/zitadel/zitadel/internal/integration" ) var ( diff --git a/internal/api/scim/integration_test/users_create_test.go b/internal/api/scim/integration_test/users_create_test.go index 1a3e2b8dd5..b9bc708d95 100644 --- a/internal/api/scim/integration_test/users_create_test.go +++ b/internal/api/scim/integration_test/users_create_test.go @@ -5,22 +5,24 @@ package integration_test import ( "context" _ "embed" + "net/http" + "path" + "testing" + "time" + "github.com/brianvoe/gofakeit/v6" "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "golang.org/x/text/language" + "google.golang.org/grpc/codes" + "github.com/zitadel/zitadel/internal/api/scim/resources" "github.com/zitadel/zitadel/internal/api/scim/schemas" "github.com/zitadel/zitadel/internal/integration" "github.com/zitadel/zitadel/internal/integration/scim" "github.com/zitadel/zitadel/pkg/grpc/management" "github.com/zitadel/zitadel/pkg/grpc/user/v2" - "golang.org/x/text/language" - "google.golang.org/grpc/codes" - "net/http" - "path" - "testing" - "time" ) var ( diff --git a/internal/api/scim/integration_test/users_delete_test.go b/internal/api/scim/integration_test/users_delete_test.go index 88c7bf88ef..bfdd0eae88 100644 --- a/internal/api/scim/integration_test/users_delete_test.go +++ b/internal/api/scim/integration_test/users_delete_test.go @@ -4,16 +4,18 @@ package integration_test import ( "context" - "github.com/brianvoe/gofakeit/v6" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/zitadel/zitadel/internal/integration" - "github.com/zitadel/zitadel/internal/integration/scim" - "github.com/zitadel/zitadel/pkg/grpc/user/v2" - "google.golang.org/grpc/codes" "net/http" "testing" "time" + + "github.com/brianvoe/gofakeit/v6" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + + "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/internal/integration/scim" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) func TestDeleteUser_errors(t *testing.T) { diff --git a/internal/api/scim/integration_test/users_get_test.go b/internal/api/scim/integration_test/users_get_test.go index 0790b591c7..a8055db600 100644 --- a/internal/api/scim/integration_test/users_get_test.go +++ b/internal/api/scim/integration_test/users_get_test.go @@ -4,21 +4,23 @@ package integration_test import ( "context" + "net/http" + "path" + "testing" + "time" + "github.com/brianvoe/gofakeit/v6" "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "golang.org/x/text/language" + "github.com/zitadel/zitadel/internal/api/scim/resources" "github.com/zitadel/zitadel/internal/api/scim/schemas" "github.com/zitadel/zitadel/internal/integration" "github.com/zitadel/zitadel/internal/integration/scim" "github.com/zitadel/zitadel/pkg/grpc/management" "github.com/zitadel/zitadel/pkg/grpc/user/v2" - "golang.org/x/text/language" - "net/http" - "path" - "testing" - "time" ) func TestGetUser(t *testing.T) { diff --git a/internal/api/scim/integration_test/users_replace_test.go b/internal/api/scim/integration_test/users_replace_test.go index 664364bbee..b43dd3acf0 100644 --- a/internal/api/scim/integration_test/users_replace_test.go +++ b/internal/api/scim/integration_test/users_replace_test.go @@ -5,20 +5,22 @@ package integration_test import ( "context" _ "embed" + "net/http" + "path" + "testing" + "time" + "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "golang.org/x/text/language" + "github.com/zitadel/zitadel/internal/api/scim/resources" "github.com/zitadel/zitadel/internal/api/scim/schemas" "github.com/zitadel/zitadel/internal/integration" "github.com/zitadel/zitadel/internal/integration/scim" "github.com/zitadel/zitadel/pkg/grpc/management" "github.com/zitadel/zitadel/pkg/grpc/user/v2" - "golang.org/x/text/language" - "net/http" - "path" - "testing" - "time" ) var ( diff --git a/internal/command/instance.go b/internal/command/instance.go index 144378ce58..99075ccfad 100644 --- a/internal/command/instance.go +++ b/internal/command/instance.go @@ -4,9 +4,9 @@ import ( "context" "time" + "github.com/zitadel/logging" "golang.org/x/text/language" - "github.com/zitadel/logging" "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/command/preparation" diff --git a/internal/command/milestone.go b/internal/command/milestone.go index 11e6e5ab7f..e2f4fdc9de 100644 --- a/internal/command/milestone.go +++ b/internal/command/milestone.go @@ -4,6 +4,7 @@ import ( "context" "github.com/zitadel/logging" + "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/command/preparation" "github.com/zitadel/zitadel/internal/eventstore" diff --git a/internal/command/project_member_model.go b/internal/command/project_member_model.go index 4e78fb4f52..8e743e0a46 100644 --- a/internal/command/project_member_model.go +++ b/internal/command/project_member_model.go @@ -58,9 +58,9 @@ func (wm *ProjectMemberWriteModel) Query() *eventstore.SearchQueryBuilder { AddQuery(). AggregateTypes(project.AggregateType). AggregateIDs(wm.MemberWriteModel.AggregateID). - EventTypes(project.MemberAddedType, - project.MemberChangedType, - project.MemberRemovedType, - project.MemberCascadeRemovedType). + EventTypes(project.MemberAddedEventType, + project.MemberChangedEventType, + project.MemberRemovedEventType, + project.MemberCascadeRemovedEventType). Builder() } diff --git a/internal/eventstore/handler/v2/field_handler.go b/internal/eventstore/handler/v2/field_handler.go index bbe40ed465..ad309ac790 100644 --- a/internal/eventstore/handler/v2/field_handler.go +++ b/internal/eventstore/handler/v2/field_handler.go @@ -32,6 +32,9 @@ func (f *fieldProjection) Reducers() []AggregateReducer { var _ Projection = (*fieldProjection)(nil) +// NewFieldHandler returns a projection handler which backfills the `eventstore.fields` table with historic events which +// might have existed before they had and Field Operations defined. +// The events are filtered by the mapped aggregate types and each event type for that aggregate. func NewFieldHandler(config *Config, name string, eventTypes map[eventstore.AggregateType][]eventstore.EventType) *FieldHandler { return &FieldHandler{ Handler: Handler{ @@ -51,6 +54,7 @@ func NewFieldHandler(config *Config, name string, eventTypes map[eventstore.Aggr } } +// Trigger executes the backfill job of events for the instance currently in the context. func (h *FieldHandler) Trigger(ctx context.Context, opts ...TriggerOpt) (err error) { config := new(triggerConfig) for _, opt := range opts { diff --git a/internal/eventstore/v3/event.go b/internal/eventstore/v3/event.go index da4e7a0383..1141a9eacf 100644 --- a/internal/eventstore/v3/event.go +++ b/internal/eventstore/v3/event.go @@ -7,6 +7,7 @@ import ( "time" "github.com/zitadel/logging" + "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/zerrors" diff --git a/internal/query/projection/eventstore_field.go b/internal/query/projection/eventstore_field.go index 59dde7507d..5dbdad717a 100644 --- a/internal/query/projection/eventstore_field.go +++ b/internal/query/projection/eventstore_field.go @@ -12,6 +12,7 @@ const ( fieldsProjectGrant = "project_grant_fields" fieldsOrgDomainVerified = "org_domain_verified_fields" fieldsInstanceDomain = "instance_domain_fields" + fieldsMemberships = "membership_fields" ) func newFillProjectGrantFields(config handler.Config) *handler.FieldHandler { @@ -52,3 +53,33 @@ func newFillInstanceDomainFields(config handler.Config) *handler.FieldHandler { }, ) } + +func newFillMembershipFields(config handler.Config) *handler.FieldHandler { + return handler.NewFieldHandler( + &config, + fieldsMemberships, + map[eventstore.AggregateType][]eventstore.EventType{ + instance.AggregateType: { + instance.MemberAddedEventType, + instance.MemberChangedEventType, + instance.MemberRemovedEventType, + instance.MemberCascadeRemovedEventType, + instance.InstanceRemovedEventType, + }, + org.AggregateType: { + org.MemberAddedEventType, + org.MemberChangedEventType, + org.MemberRemovedEventType, + org.MemberCascadeRemovedEventType, + org.OrgRemovedEventType, + }, + project.AggregateType: { + project.MemberAddedEventType, + project.MemberChangedEventType, + project.MemberRemovedEventType, + project.MemberCascadeRemovedEventType, + project.ProjectRemovedType, + }, + }, + ) +} diff --git a/internal/query/projection/project_member.go b/internal/query/projection/project_member.go index 822e2e8d7e..8f03192019 100644 --- a/internal/query/projection/project_member.go +++ b/internal/query/projection/project_member.go @@ -60,19 +60,19 @@ func (p *projectMemberProjection) Reducers() []handler.AggregateReducer { Aggregate: project.AggregateType, EventReducers: []handler.EventReducer{ { - Event: project.MemberAddedType, + Event: project.MemberAddedEventType, Reduce: p.reduceAdded, }, { - Event: project.MemberChangedType, + Event: project.MemberChangedEventType, Reduce: p.reduceChanged, }, { - Event: project.MemberCascadeRemovedType, + Event: project.MemberCascadeRemovedEventType, Reduce: p.reduceCascadeRemoved, }, { - Event: project.MemberRemovedType, + Event: project.MemberRemovedEventType, Reduce: p.reduceRemoved, }, { @@ -114,7 +114,7 @@ func (p *projectMemberProjection) Reducers() []handler.AggregateReducer { func (p *projectMemberProjection) reduceAdded(event eventstore.Event) (*handler.Statement, error) { e, ok := event.(*project.MemberAddedEvent) if !ok { - return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-bgx5Q", "reduce.wrong.event.type %s", project.MemberAddedType) + return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-bgx5Q", "reduce.wrong.event.type %s", project.MemberAddedEventType) } ctx := setMemberContext(e.Aggregate()) userOwner, err := getUserResourceOwner(ctx, p.es, e.Aggregate().InstanceID, e.UserID) @@ -131,7 +131,7 @@ func (p *projectMemberProjection) reduceAdded(event eventstore.Event) (*handler. func (p *projectMemberProjection) reduceChanged(event eventstore.Event) (*handler.Statement, error) { e, ok := event.(*project.MemberChangedEvent) if !ok { - return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-90WJ1", "reduce.wrong.event.type %s", project.MemberChangedType) + return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-90WJ1", "reduce.wrong.event.type %s", project.MemberChangedEventType) } return reduceMemberChanged( *member.NewMemberChangedEvent(&e.BaseEvent, e.UserID, e.Roles...), @@ -142,7 +142,7 @@ func (p *projectMemberProjection) reduceChanged(event eventstore.Event) (*handle func (p *projectMemberProjection) reduceCascadeRemoved(event eventstore.Event) (*handler.Statement, error) { e, ok := event.(*project.MemberCascadeRemovedEvent) if !ok { - return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-aGd43", "reduce.wrong.event.type %s", project.MemberCascadeRemovedType) + return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-aGd43", "reduce.wrong.event.type %s", project.MemberCascadeRemovedEventType) } return reduceMemberCascadeRemoved( *member.NewCascadeRemovedEvent(&e.BaseEvent, e.UserID), @@ -153,7 +153,7 @@ func (p *projectMemberProjection) reduceCascadeRemoved(event eventstore.Event) ( func (p *projectMemberProjection) reduceRemoved(event eventstore.Event) (*handler.Statement, error) { e, ok := event.(*project.MemberRemovedEvent) if !ok { - return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-eJZPh", "reduce.wrong.event.type %s", project.MemberRemovedType) + return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-eJZPh", "reduce.wrong.event.type %s", project.MemberRemovedEventType) } return reduceMemberRemoved(e, withMemberCond(MemberUserIDCol, e.UserID), diff --git a/internal/query/projection/project_member_test.go b/internal/query/projection/project_member_test.go index bd7e1049cf..c33a319524 100644 --- a/internal/query/projection/project_member_test.go +++ b/internal/query/projection/project_member_test.go @@ -32,7 +32,7 @@ func TestProjectMemberProjection_reduces(t *testing.T) { args: args{ event: getEvent( testEvent( - project.MemberAddedType, + project.MemberAddedEventType, project.AggregateType, []byte(`{ "userId": "user-id", @@ -56,7 +56,7 @@ func TestProjectMemberProjection_reduces(t *testing.T) { args: args{ event: getEvent( testEvent( - project.MemberAddedType, + project.MemberAddedEventType, project.AggregateType, []byte(`{ "userId": "user-id", @@ -110,7 +110,7 @@ func TestProjectMemberProjection_reduces(t *testing.T) { args: args{ event: getEvent( testEvent( - project.MemberAddedType, + project.MemberAddedEventType, project.AggregateType, []byte(`{ "userId": "user-id", @@ -176,7 +176,7 @@ func TestProjectMemberProjection_reduces(t *testing.T) { args: args{ event: getEvent( testEvent( - project.MemberChangedType, + project.MemberChangedEventType, project.AggregateType, []byte(`{ "userId": "user-id", @@ -210,7 +210,7 @@ func TestProjectMemberProjection_reduces(t *testing.T) { args: args{ event: getEvent( testEvent( - project.MemberCascadeRemovedType, + project.MemberCascadeRemovedEventType, project.AggregateType, []byte(`{ "userId": "user-id" @@ -240,7 +240,7 @@ func TestProjectMemberProjection_reduces(t *testing.T) { args: args{ event: getEvent( testEvent( - project.MemberRemovedType, + project.MemberRemovedEventType, project.AggregateType, []byte(`{ "userId": "user-id" diff --git a/internal/query/projection/projection.go b/internal/query/projection/projection.go index ebe7454b58..d6647d0961 100644 --- a/internal/query/projection/projection.go +++ b/internal/query/projection/projection.go @@ -85,6 +85,7 @@ var ( ProjectGrantFields *handler.FieldHandler OrgDomainVerifiedFields *handler.FieldHandler InstanceDomainFields *handler.FieldHandler + MembershipFields *handler.FieldHandler ) type projection interface { @@ -174,6 +175,7 @@ func Create(ctx context.Context, sqlClient *database.DB, es handler.EventStore, ProjectGrantFields = newFillProjectGrantFields(applyCustomConfig(projectionConfig, config.Customizations[fieldsProjectGrant])) OrgDomainVerifiedFields = newFillOrgDomainVerifiedFields(applyCustomConfig(projectionConfig, config.Customizations[fieldsOrgDomainVerified])) InstanceDomainFields = newFillInstanceDomainFields(applyCustomConfig(projectionConfig, config.Customizations[fieldsInstanceDomain])) + MembershipFields = newFillMembershipFields(applyCustomConfig(projectionConfig, config.Customizations[fieldsMemberships])) newProjectionsList() return nil diff --git a/internal/repository/project/eventstore.go b/internal/repository/project/eventstore.go index 5705649739..2648737d3b 100644 --- a/internal/repository/project/eventstore.go +++ b/internal/repository/project/eventstore.go @@ -10,10 +10,10 @@ func init() { eventstore.RegisterFilterEventMapper(AggregateType, ProjectDeactivatedType, ProjectDeactivatedEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, ProjectReactivatedType, ProjectReactivatedEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, ProjectRemovedType, ProjectRemovedEventMapper) - eventstore.RegisterFilterEventMapper(AggregateType, MemberAddedType, MemberAddedEventMapper) - eventstore.RegisterFilterEventMapper(AggregateType, MemberChangedType, MemberChangedEventMapper) - eventstore.RegisterFilterEventMapper(AggregateType, MemberRemovedType, MemberRemovedEventMapper) - eventstore.RegisterFilterEventMapper(AggregateType, MemberCascadeRemovedType, MemberCascadeRemovedEventMapper) + eventstore.RegisterFilterEventMapper(AggregateType, MemberAddedEventType, MemberAddedEventMapper) + eventstore.RegisterFilterEventMapper(AggregateType, MemberChangedEventType, MemberChangedEventMapper) + eventstore.RegisterFilterEventMapper(AggregateType, MemberRemovedEventType, MemberRemovedEventMapper) + eventstore.RegisterFilterEventMapper(AggregateType, MemberCascadeRemovedEventType, MemberCascadeRemovedEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, RoleAddedType, RoleAddedEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, RoleChangedType, RoleChangedEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, RoleRemovedType, RoleRemovedEventMapper) diff --git a/internal/repository/project/member.go b/internal/repository/project/member.go index d04709b5fa..6fb3ceddfe 100644 --- a/internal/repository/project/member.go +++ b/internal/repository/project/member.go @@ -8,10 +8,10 @@ import ( ) var ( - MemberAddedType = projectEventTypePrefix + member.AddedEventType - MemberChangedType = projectEventTypePrefix + member.ChangedEventType - MemberRemovedType = projectEventTypePrefix + member.RemovedEventType - MemberCascadeRemovedType = projectEventTypePrefix + member.CascadeRemovedEventType + MemberAddedEventType = projectEventTypePrefix + member.AddedEventType + MemberChangedEventType = projectEventTypePrefix + member.ChangedEventType + MemberRemovedEventType = projectEventTypePrefix + member.RemovedEventType + MemberCascadeRemovedEventType = projectEventTypePrefix + member.CascadeRemovedEventType ) const ( @@ -37,7 +37,7 @@ func NewProjectMemberAddedEvent( eventstore.NewBaseEventForPush( ctx, aggregate, - MemberAddedType, + MemberAddedEventType, ), userID, roles..., @@ -74,7 +74,7 @@ func NewProjectMemberChangedEvent( eventstore.NewBaseEventForPush( ctx, aggregate, - MemberChangedType, + MemberChangedEventType, ), userID, roles..., @@ -110,7 +110,7 @@ func NewProjectMemberRemovedEvent( eventstore.NewBaseEventForPush( ctx, aggregate, - MemberRemovedType, + MemberRemovedEventType, ), userID, ), @@ -145,7 +145,7 @@ func NewProjectMemberCascadeRemovedEvent( eventstore.NewBaseEventForPush( ctx, aggregate, - MemberCascadeRemovedType, + MemberCascadeRemovedEventType, ), userID, ),