From 8fff45d8f4777dafcaf1a61d5b112b5f9ac825e0 Mon Sep 17 00:00:00 2001 From: Gayathri Vijayan <66356931+grvijayan@users.noreply.github.com> Date: Wed, 23 Jul 2025 10:47:05 +0200 Subject: [PATCH] fix(scim): add a metadata config to ignore random password sent during SCIM create (#10296) # Which Problems Are Solved Okta sends a random password in the request to create a user during SCIM provisioning, irrespective of whether the `Sync Password` option is enabled or disabled on Okta, and this password does not comply with the default password complexity set in Zitadel. This PR adds a workaround to create users without issues in such cases. # How the Problems Are Solved - A new metadata configuration called `urn:zitadel:scim:ignorePasswordOnCreate` is added to the Machine User that is used for provisioning - During SCIM user creation requests, if the `urn:zitadel:scim:ignorePasswordOnCreate` is set to `true` in the Machine User's metadata, the password set in the create request is ignored # Additional Changes # Additional Context The random password is ignored (if set in the metadata) only during customer creation. This change does not affect SCIM password updates. - Closes #10009 --------- Co-authored-by: Marco A. --- .../integration_test/users_create_test.go | 75 +++++++++++++++++++ internal/api/scim/metadata/context.go | 1 + internal/api/scim/metadata/metadata.go | 5 +- .../middleware/scim_context_middleware.go | 38 ++++++++-- internal/api/scim/resources/user_mapping.go | 7 +- internal/api/scim/resources/user_metadata.go | 10 +-- 6 files changed, 122 insertions(+), 14 deletions(-) diff --git a/internal/api/scim/integration_test/users_create_test.go b/internal/api/scim/integration_test/users_create_test.go index 4d1d9268ce..71356c7b43 100644 --- a/internal/api/scim/integration_test/users_create_test.go +++ b/internal/api/scim/integration_test/users_create_test.go @@ -432,3 +432,78 @@ func TestCreateUser_scopedExternalID(t *testing.T) { assert.Equal(tt, "701984", string(md.Metadata.Value)) }, retryDuration, tick) } + +func TestCreateUser_ignorePasswordOnCreate(t *testing.T) { + t.Parallel() + tests := []struct { + name string + ignorePassword string + scimErrorType string + scimErrorDetail string + wantUser *resources.ScimUser + wantErr bool + }{ + { + name: "ignorePasswordOnCreate set to false", + ignorePassword: "false", + wantErr: true, + scimErrorType: "invalidValue", + scimErrorDetail: "Password is too short", + }, + { + name: "ignorePasswordOnCreate set to an invalid value", + ignorePassword: "random", + wantErr: true, + scimErrorType: "invalidValue", + scimErrorDetail: "Invalid value for metadata key urn:zitadel:scim:ignorePasswordOnCreate: random", + }, + { + name: "ignorePasswordOnCreate set to true", + ignorePassword: "true", + wantUser: &resources.ScimUser{ + UserName: "acmeUser1", + Name: &resources.ScimUserName{ + FamilyName: "Ross", + GivenName: "Bethany", + }, + Emails: []*resources.ScimEmail{ + { + Value: "user1@example.com", + Primary: true, + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + // create a machine user + callingUserId, callingUserPat, err := Instance.CreateMachineUserPATWithMembership(CTX, "ORG_OWNER") + require.NoError(t, err) + ctx := integration.WithAuthorizationToken(CTX, callingUserPat) + + // set urn:zitadel:scim:ignorePasswordOnCreate metadata for the machine user + setAndEnsureMetadata(t, callingUserId, "urn:zitadel:scim:ignorePasswordOnCreate", tt.ignorePassword) + + // create a user with an invalid password + createdUser, err := Instance.Client.SCIM.Users.Create(ctx, Instance.DefaultOrg.Id, withUsername(invalidPasswordUserJson, "acmeUser1")) + require.Equal(t, tt.wantErr, err != nil) + if err != nil { + scimErr := scim.RequireScimError(t, http.StatusBadRequest, err) + assert.Equal(t, tt.scimErrorType, scimErr.Error.ScimType) + assert.Equal(t, tt.scimErrorDetail, scimErr.Error.Detail) + return + } + + 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) + assert.True(ttt, test.PartiallyDeepEqual(tt.wantUser, fetchedUser)) + }, retryDuration, tick) + }) + } +} diff --git a/internal/api/scim/metadata/context.go b/internal/api/scim/metadata/context.go index dd8ef54d8e..2d0d76b928 100644 --- a/internal/api/scim/metadata/context.go +++ b/internal/api/scim/metadata/context.go @@ -15,6 +15,7 @@ var scimContextKey scimContextKeyType type ScimContextData struct { ProvisioningDomain string + IgnorePasswordOnCreate bool ExternalIDScopedMetadataKey ScopedKey bulkIDMapping map[string]string } diff --git a/internal/api/scim/metadata/metadata.go b/internal/api/scim/metadata/metadata.go index 28e42290d1..eb20284821 100644 --- a/internal/api/scim/metadata/metadata.go +++ b/internal/api/scim/metadata/metadata.go @@ -13,8 +13,9 @@ type ScopedKey string const ( externalIdProvisioningDomainPlaceholder = "{provisioningDomain}" - KeyPrefix = "urn:zitadel:scim:" - KeyProvisioningDomain Key = KeyPrefix + "provisioningDomain" + KeyPrefix = "urn:zitadel:scim:" + KeyProvisioningDomain Key = KeyPrefix + "provisioningDomain" + KeyIgnorePasswordOnCreate Key = KeyPrefix + "ignorePasswordOnCreate" KeyExternalId Key = KeyPrefix + "externalId" keyScopedExternalIdTemplate = KeyPrefix + externalIdProvisioningDomainPlaceholder + ":externalId" diff --git a/internal/api/scim/middleware/scim_context_middleware.go b/internal/api/scim/middleware/scim_context_middleware.go index 1ec917e18b..e9b6f48aa2 100644 --- a/internal/api/scim/middleware/scim_context_middleware.go +++ b/internal/api/scim/middleware/scim_context_middleware.go @@ -3,10 +3,15 @@ package middleware import ( "context" "net/http" + "strconv" + "strings" + + "github.com/zitadel/logging" "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" + sresources "github.com/zitadel/zitadel/internal/api/scim/resources" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/zerrors" ) @@ -29,22 +34,43 @@ func initScimContext(ctx context.Context, q *query.Queries) (context.Context, er ctx = smetadata.SetScimContextData(ctx, data) userID := authz.GetCtxData(ctx).UserID - metadata, err := q.GetUserMetadataByKey(ctx, false, userID, string(smetadata.KeyProvisioningDomain), false) + + // get the provisioningDomain and ignorePasswordOnCreate metadata keys associated with the service user + metadataKeys := []smetadata.Key{ + smetadata.KeyProvisioningDomain, + smetadata.KeyIgnorePasswordOnCreate, + } + queries := sresources.BuildMetadataQueries(ctx, metadataKeys) + + metadataList, err := q.SearchUserMetadata(ctx, false, userID, queries, nil) if err != nil { if zerrors.IsNotFound(err) { return ctx, nil } - return ctx, err } - if metadata == nil { + if metadataList == nil || len(metadataList.Metadata) == 0 { return ctx, nil } - data.ProvisioningDomain = string(metadata.Value) - if data.ProvisioningDomain != "" { - data.ExternalIDScopedMetadataKey = smetadata.ScopeExternalIdKey(data.ProvisioningDomain) + for _, metadata := range metadataList.Metadata { + switch metadata.Key { + case string(smetadata.KeyProvisioningDomain): + data.ProvisioningDomain = string(metadata.Value) + if data.ProvisioningDomain != "" { + data.ExternalIDScopedMetadataKey = smetadata.ScopeExternalIdKey(data.ProvisioningDomain) + } + case string(smetadata.KeyIgnorePasswordOnCreate): + ignorePasswordOnCreate, parseErr := strconv.ParseBool(strings.TrimSpace(string(metadata.Value))) + if parseErr != nil { + return ctx, + zerrors.ThrowInvalidArgumentf(nil, "SMCM-yvw2rt", "Invalid value for metadata key %s: %s", smetadata.KeyIgnorePasswordOnCreate, metadata.Value) + } + data.IgnorePasswordOnCreate = ignorePasswordOnCreate + default: + logging.WithFields("user_metadata_key", metadata.Key).Warn("unexpected metadata key") + } } return smetadata.SetScimContextData(ctx, data), nil } diff --git a/internal/api/scim/resources/user_mapping.go b/internal/api/scim/resources/user_mapping.go index 260e50846a..0aac888173 100644 --- a/internal/api/scim/resources/user_mapping.go +++ b/internal/api/scim/resources/user_mapping.go @@ -45,7 +45,12 @@ func (h *UsersHandler) mapToAddHuman(ctx context.Context, scimUser *ScimUser) (* } human.Metadata = md - if scimUser.Password != nil { + // Okta sends a random password during SCIM provisioning + // irrespective of whether the Sync Password option is enabled or disabled on Okta. + // This password does not comply with Zitadel's password complexity, and + // the following workaround ignores the random password as it does not add any value. + ignorePasswordOnCreate := metadata.GetScimContextData(ctx).IgnorePasswordOnCreate + if scimUser.Password != nil && !ignorePasswordOnCreate { human.Password = scimUser.Password.String() scimUser.Password = nil } diff --git a/internal/api/scim/resources/user_metadata.go b/internal/api/scim/resources/user_metadata.go index b758117ce8..8cf8693691 100644 --- a/internal/api/scim/resources/user_metadata.go +++ b/internal/api/scim/resources/user_metadata.go @@ -21,7 +21,7 @@ import ( ) func (h *UsersHandler) queryMetadataForUsers(ctx context.Context, userIds []string) (map[string]map[metadata.ScopedKey][]byte, error) { - queries := h.buildMetadataQueries(ctx) + queries := BuildMetadataQueries(ctx, metadata.ScimUserRelevantMetadataKeys) md, err := h.query.SearchUserMetadataForUsers(ctx, false, userIds, queries) if err != nil { @@ -43,7 +43,7 @@ func (h *UsersHandler) queryMetadataForUsers(ctx context.Context, userIds []stri } func (h *UsersHandler) queryMetadataForUser(ctx context.Context, id string) (map[metadata.ScopedKey][]byte, error) { - queries := h.buildMetadataQueries(ctx) + queries := BuildMetadataQueries(ctx, metadata.ScimUserRelevantMetadataKeys) md, err := h.query.SearchUserMetadata(ctx, false, id, queries, nil) if err != nil { @@ -53,9 +53,9 @@ func (h *UsersHandler) queryMetadataForUser(ctx context.Context, id string) (map return metadata.MapListToScopedKeyMap(md.Metadata), nil } -func (h *UsersHandler) buildMetadataQueries(ctx context.Context) *query.UserMetadataSearchQueries { - keyQueries := make([]query.SearchQuery, len(metadata.ScimUserRelevantMetadataKeys)) - for i, key := range metadata.ScimUserRelevantMetadataKeys { +func BuildMetadataQueries(ctx context.Context, metadataKeys []metadata.Key) *query.UserMetadataSearchQueries { + keyQueries := make([]query.SearchQuery, len(metadataKeys)) + for i, key := range metadataKeys { keyQueries[i] = buildMetadataKeyQuery(ctx, key) }