mirror of
https://github.com/zitadel/zitadel.git
synced 2025-03-01 15:17:23 +00:00

# Which Problems Are Solved - Microsoft Entra invokes the user patch endpoint with `"active": "True"` / `"active": "False"` when patching a user. This is a well-known bug in MS Entra (see [here](https://learn.microsoft.com/en-us/entra/identity/app-provisioning/application-provisioning-config-problem-scim-compatibility)), but the bug fix has not landed yet and/or the feature flag does not work. # How the Problems Are Solved - To ensure compatibility with MS Entra, the parsing of the the boolean active flag of the scim user is relaxed and accepts strings in any casing that resolve to `true` or `false` as well as raw boolean values. # Additional Context Part of https://github.com/zitadel/zitadel/issues/8140
356 lines
11 KiB
Go
356 lines
11 KiB
Go
//go:build integration
|
|
|
|
package integration_test
|
|
|
|
import (
|
|
"context"
|
|
_ "embed"
|
|
"net/http"
|
|
"path"
|
|
"testing"
|
|
"time"
|
|
|
|
"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/internal/test"
|
|
"github.com/zitadel/zitadel/pkg/grpc/management"
|
|
"github.com/zitadel/zitadel/pkg/grpc/user/v2"
|
|
)
|
|
|
|
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
|
|
createUserOrgID string
|
|
replaceUserOrgID string
|
|
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: test.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: *test.Must(schemas.ParseHTTPURL("https://photos.example.com/profilephoto/72930000000Ccne/F-updated")),
|
|
Type: "photo-updated",
|
|
},
|
|
{
|
|
Value: *test.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: schemas.NewRelaxedBool(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,
|
|
},
|
|
{
|
|
name: "another org",
|
|
body: minimalUserJson,
|
|
replaceUserOrgID: SecondaryOrganization.OrganizationId,
|
|
wantErr: true,
|
|
errorStatus: http.StatusNotFound,
|
|
},
|
|
{
|
|
name: "another org with permissions",
|
|
body: minimalUserJson,
|
|
replaceUserOrgID: SecondaryOrganization.OrganizationId,
|
|
ctx: Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner),
|
|
wantErr: true,
|
|
errorStatus: http.StatusNotFound,
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// use iam owner => we don't want to test permissions of the create endpoint.
|
|
createdUser, err := Instance.Client.SCIM.Users.Create(Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner), 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
|
|
}
|
|
|
|
replaceUserOrgID := tt.replaceUserOrgID
|
|
if replaceUserOrgID == "" {
|
|
replaceUserOrgID = Instance.DefaultOrg.Id
|
|
}
|
|
|
|
replacedUser, err := Instance.Client.SCIM.Users.Replace(ctx, replaceUserOrgID, 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 !test.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 !test.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:provisioningDomain",
|
|
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
|
|
test.AssertMapContains(tt, mdMap, "urn:zitadel:scim:externalId", "701984")
|
|
test.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:provisioningDomain",
|
|
})
|
|
require.NoError(t, err)
|
|
}
|