mirror of
https://github.com/zitadel/zitadel.git
synced 2025-07-10 18:48:31 +00:00
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
This commit is contained in:
parent
84997ffe1a
commit
d01d003a03
@ -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,
|
||||
},
|
||||
|
17
internal/api/scim/integration_test/testdata/users_create_test_minimal_inactive.json
vendored
Normal file
17
internal/api/scim/integration_test/testdata/users_create_test_minimal_inactive.json
vendored
Normal file
@ -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
|
||||
}
|
116
internal/api/scim/integration_test/testdata/users_replace_test_full.json
vendored
Normal file
116
internal/api/scim/integration_test/testdata/users_replace_test_full.json
vendored
Normal file
@ -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"
|
||||
}
|
16
internal/api/scim/integration_test/testdata/users_replace_test_minimal.json
vendored
Normal file
16
internal/api/scim/integration_test/testdata/users_replace_test_minimal.json
vendored
Normal file
@ -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
|
||||
}
|
||||
]
|
||||
}
|
17
internal/api/scim/integration_test/testdata/users_replace_test_minimal_with_external_id.json
vendored
Normal file
17
internal/api/scim/integration_test/testdata/users_replace_test_minimal_with_external_id.json
vendored
Normal file
@ -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
|
||||
}
|
||||
]
|
||||
}
|
@ -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) {
|
||||
|
@ -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) {
|
||||
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
329
internal/api/scim/integration_test/users_replace_test.go
Normal file
329
internal/api/scim/integration_test/users_replace_test.go
Normal file
@ -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)
|
||||
}
|
@ -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)
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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 {
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
|
@ -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)
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user