fix(scim): add a metadata config to ignore random password sent during SCIM create (#10296)

<!--
Please inform yourself about the contribution guidelines on submitting a
PR here:
https://github.com/zitadel/zitadel/blob/main/CONTRIBUTING.md#submit-a-pull-request-pr.
Take note of how PR/commit titles should be written and replace the
template texts in the sections below. Don't remove any of the sections.
It is important that the commit history clearly shows what is changed
and why.
Important: By submitting a contribution you agree to the terms from our
Licensing Policy as described here:
https://github.com/zitadel/zitadel/blob/main/LICENSING.md#community-contributions.
-->

# 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. <marco@zitadel.com>
This commit is contained in:
Gayathri Vijayan
2025-07-23 10:47:05 +02:00
committed by Tim Möhlmann
parent 213f15164d
commit c446cd27c9
6 changed files with 122 additions and 14 deletions

View File

@@ -426,3 +426,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)
})
}
}

View File

@@ -15,6 +15,7 @@ var scimContextKey scimContextKeyType
type ScimContextData struct {
ProvisioningDomain string
IgnorePasswordOnCreate bool
ExternalIDScopedMetadataKey ScopedKey
bulkIDMapping map[string]string
}

View File

@@ -15,6 +15,7 @@ const (
KeyPrefix = "urn:zitadel:scim:"
KeyProvisioningDomain Key = KeyPrefix + "provisioningDomain"
KeyIgnorePasswordOnCreate Key = KeyPrefix + "ignorePasswordOnCreate"
KeyExternalId Key = KeyPrefix + "externalId"
keyScopedExternalIdTemplate = KeyPrefix + externalIdProvisioningDomainPlaceholder + ":externalId"

View File

@@ -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
}
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
}

View File

@@ -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
}

View File

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