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:
Lars 2025-01-14 15:44:41 +01:00 committed by GitHub
parent 84997ffe1a
commit d01d003a03
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 1029 additions and 95 deletions

View File

@ -13,6 +13,9 @@ var AuthMapping = authz.MethodMapping{
"GET:/scim/v2/" + http.OrgIdInPathVariable + "/Users/{id}": { "GET:/scim/v2/" + http.OrgIdInPathVariable + "/Users/{id}": {
Permission: domain.PermissionUserRead, Permission: domain.PermissionUserRead,
}, },
"PUT:/scim/v2/" + http.OrgIdInPathVariable + "/Users/{id}": {
Permission: domain.PermissionUserWrite,
},
"DELETE:/scim/v2/" + http.OrgIdInPathVariable + "/Users/{id}": { "DELETE:/scim/v2/" + http.OrgIdInPathVariable + "/Users/{id}": {
Permission: domain.PermissionUserDelete, Permission: domain.PermissionUserDelete,
}, },

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

View 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"
}

View 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
}
]
}

View 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
}
]
}

View File

@ -6,23 +6,30 @@ import (
"context" "context"
_ "embed" _ "embed"
"github.com/brianvoe/gofakeit/v6" "github.com/brianvoe/gofakeit/v6"
"github.com/muhlemmer/gu"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "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/api/scim/schemas"
"github.com/zitadel/zitadel/internal/integration" "github.com/zitadel/zitadel/internal/integration"
"github.com/zitadel/zitadel/internal/integration/scim" "github.com/zitadel/zitadel/internal/integration/scim"
"github.com/zitadel/zitadel/pkg/grpc/management" "github.com/zitadel/zitadel/pkg/grpc/management"
"github.com/zitadel/zitadel/pkg/grpc/user/v2" "github.com/zitadel/zitadel/pkg/grpc/user/v2"
"golang.org/x/text/language"
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
"net/http" "net/http"
"path" "path"
"testing" "testing"
"time"
) )
var ( var (
//go:embed testdata/users_create_test_minimal.json //go:embed testdata/users_create_test_minimal.json
minimalUserJson []byte minimalUserJson []byte
//go:embed testdata/users_create_test_minimal_inactive.json
minimalInactiveUserJson []byte
//go:embed testdata/users_create_test_full.json //go:embed testdata/users_create_test_full.json
fullUserJson []byte fullUserJson []byte
@ -53,6 +60,7 @@ func TestCreateUser(t *testing.T) {
name string name string
body []byte body []byte
ctx context.Context ctx context.Context
want *resources.ScimUser
wantErr bool wantErr bool
scimErrorType string scimErrorType string
errorStatus int errorStatus int
@ -61,10 +69,127 @@ func TestCreateUser(t *testing.T) {
{ {
name: "minimal user", name: "minimal user",
body: minimalUserJson, 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", name: "full user",
body: fullUserJson, 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", name: "missing userName",
@ -152,13 +277,31 @@ func TestCreateUser(t *testing.T) {
} }
assert.NotEmpty(t, createdUser.ID) 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.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, 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.Equal(t, "http://"+Instance.Host()+path.Join(schemas.HandlerPrefix, Instance.DefaultOrg.Id, "Users", createdUser.ID), createdUser.Resource.Meta.Location)
assert.Nil(t, createdUser.Password) assert.Nil(t, createdUser.Password)
_, err = Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: createdUser.ID}) if tt.want != nil {
assert.NoError(t, err) 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) createdUser, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, fullUserJson)
require.NoError(t, err) require.NoError(t, err)
defer func() {
_, 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) {
md, err := Instance.Client.Mgmt.ListUserMetadata(CTX, &management.ListUserMetadataRequest{ md, err := Instance.Client.Mgmt.ListUserMetadata(CTX, &management.ListUserMetadataRequest{
Id: createdUser.ID, Id: createdUser.ID,
}) })
require.NoError(t, err) require.NoError(tt, err)
mdMap := make(map[string]string) mdMap := make(map[string]string)
for i := range md.Result { for i := range md.Result {
mdMap[md.Result[i].Key] = string(md.Result[i].Value) mdMap[md.Result[i].Key] = string(md.Result[i].Value)
} }
integration.AssertMapContains(t, mdMap, "urn:zitadel:scim:name.honorificPrefix", "Ms.") integration.AssertMapContains(tt, mdMap, "urn:zitadel:scim:name.honorificPrefix", "Ms.")
integration.AssertMapContains(t, mdMap, "urn:zitadel:scim:timezone", "America/Los_Angeles") integration.AssertMapContains(tt, 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(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(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(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(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(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(t, mdMap, "urn:zitadel:scim:externalId", "701984") integration.AssertMapContains(tt, mdMap, "urn:zitadel:scim:externalId", "701984")
integration.AssertMapContains(t, mdMap, "urn:zitadel:scim:name.middleName", "Jane") integration.AssertMapContains(tt, mdMap, "urn:zitadel:scim:name.middleName", "Jane")
integration.AssertMapContains(t, mdMap, "urn:zitadel:scim:name.honorificSuffix", "III") integration.AssertMapContains(tt, mdMap, "urn:zitadel:scim:name.honorificSuffix", "III")
integration.AssertMapContains(t, mdMap, "urn:zitadel:scim:profileURL", "http://login.example.com/bjensen") integration.AssertMapContains(tt, mdMap, "urn:zitadel:scim:profileURL", "http://login.example.com/bjensen")
integration.AssertMapContains(t, mdMap, "urn:zitadel:scim:title", "Tour Guide") integration.AssertMapContains(tt, mdMap, "urn:zitadel:scim:title", "Tour Guide")
integration.AssertMapContains(t, mdMap, "urn:zitadel:scim:locale", "en-US") integration.AssertMapContains(tt, 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(tt, 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"}]`) 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)
_, err = Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: createdUser.ID})
require.NoError(t, err)
} }
func TestCreateUser_scopedExternalID(t *testing.T) { 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) createdUser, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, fullUserJson)
require.NoError(t, err) require.NoError(t, err)
defer func() {
_, 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)
}()
retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Minute)
require.EventuallyWithT(t, func(tt *assert.CollectT) {
// unscoped externalID should not exist // unscoped externalID should not exist
_, err = Instance.Client.Mgmt.GetUserMetadata(CTX, &management.GetUserMetadataRequest{ _, err = Instance.Client.Mgmt.GetUserMetadata(CTX, &management.GetUserMetadataRequest{
Id: createdUser.ID, Id: createdUser.ID,
Key: "urn:zitadel:scim:externalId", Key: "urn:zitadel:scim:externalId",
}) })
integration.AssertGrpcStatus(t, codes.NotFound, err) integration.AssertGrpcStatus(tt, codes.NotFound, err)
// scoped externalID should exist // scoped externalID should exist
md, err := Instance.Client.Mgmt.GetUserMetadata(CTX, &management.GetUserMetadataRequest{ md, err := Instance.Client.Mgmt.GetUserMetadata(CTX, &management.GetUserMetadataRequest{
Id: createdUser.ID, Id: createdUser.ID,
Key: "urn:zitadel:scim:fooBar:externalId", Key: "urn:zitadel:scim:fooBar:externalId",
}) })
require.NoError(t, err) require.NoError(tt, err)
assert.Equal(t, "701984", string(md.Metadata.Value)) assert.Equal(tt, "701984", string(md.Metadata.Value))
}, retryDuration, tick)
_, err = Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: createdUser.ID})
require.NoError(t, err)
} }
func TestCreateUser_anotherOrg(t *testing.T) { func TestCreateUser_anotherOrg(t *testing.T) {

View File

@ -13,6 +13,7 @@ import (
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
"net/http" "net/http"
"testing" "testing"
"time"
) )
func TestDeleteUser_errors(t *testing.T) { 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) err = Instance.Client.SCIM.Users.Delete(CTX, Instance.DefaultOrg.Id, createUserResp.UserId)
scim.RequireScimError(t, http.StatusNotFound, err) scim.RequireScimError(t, http.StatusNotFound, err)
retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Minute)
require.EventuallyWithT(t, func(tt *assert.CollectT) {
// try to get user via api => should 404 // try to get user via api => should 404
_, err = Instance.Client.UserV2.GetUserByID(CTX, &user.GetUserByIDRequest{UserId: createUserResp.UserId}) _, err = Instance.Client.UserV2.GetUserByID(CTX, &user.GetUserByIDRequest{UserId: createUserResp.UserId})
integration.AssertGrpcStatus(t, codes.NotFound, err) integration.AssertGrpcStatus(tt, codes.NotFound, err)
}, retryDuration, tick)
} }
func TestDeleteUser_anotherOrg(t *testing.T) { func TestDeleteUser_anotherOrg(t *testing.T) {

View File

@ -13,17 +13,19 @@ import (
"github.com/zitadel/zitadel/internal/integration" "github.com/zitadel/zitadel/internal/integration"
"github.com/zitadel/zitadel/internal/integration/scim" "github.com/zitadel/zitadel/internal/integration/scim"
"github.com/zitadel/zitadel/pkg/grpc/management" "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" "golang.org/x/text/language"
"net/http" "net/http"
"path" "path"
"testing" "testing"
"time"
) )
func TestGetUser(t *testing.T) { func TestGetUser(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
buildUserID func() (userID string, deleteUser bool) buildUserID func() string
cleanup func(userID string)
ctx context.Context ctx context.Context
want *resources.ScimUser want *resources.ScimUser
wantErr bool wantErr bool
@ -43,8 +45,8 @@ func TestGetUser(t *testing.T) {
}, },
{ {
name: "unknown user id", name: "unknown user id",
buildUserID: func() (string, bool) { buildUserID: func() string {
return "unknown", false return "unknown"
}, },
errorStatus: http.StatusNotFound, errorStatus: http.StatusNotFound,
wantErr: true, wantErr: true,
@ -67,10 +69,14 @@ func TestGetUser(t *testing.T) {
}, },
{ {
name: "created via scim", name: "created via scim",
buildUserID: func() (string, bool) { buildUserID: func() string {
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)
return createdUser.ID
},
cleanup: func(userID string) {
_, err := Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: userID})
require.NoError(t, err) require.NoError(t, err)
return user.ID, true
}, },
want: &resources.ScimUser{ want: &resources.ScimUser{
ExternalID: "701984", ExternalID: "701984",
@ -176,9 +182,9 @@ func TestGetUser(t *testing.T) {
}, },
{ {
name: "scoped externalID", name: "scoped externalID",
buildUserID: func() (string, bool) { buildUserID: func() string {
// create user without provisioning domain // 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) require.NoError(t, err)
// set provisioning domain of service user // set provisioning domain of service user
@ -191,12 +197,22 @@ func TestGetUser(t *testing.T) {
// set externalID for provisioning domain // set externalID for provisioning domain
_, err = Instance.Client.Mgmt.SetUserMetadata(CTX, &management.SetUserMetadataRequest{ _, err = Instance.Client.Mgmt.SetUserMetadata(CTX, &management.SetUserMetadataRequest{
Id: user.ID, Id: createdUser.ID,
Key: "urn:zitadel:scim:fooBar:externalId", Key: "urn:zitadel:scim:fooBar:externalId",
Value: []byte("100-scopedExternalId"), Value: []byte("100-scopedExternalId"),
}) })
require.NoError(t, err) 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{ want: &resources.ScimUser{
ExternalID: "100-scopedExternalId", ExternalID: "100-scopedExternalId",
@ -211,37 +227,40 @@ func TestGetUser(t *testing.T) {
} }
var userID string var userID string
var deleteUserAfterTest bool
if tt.buildUserID != nil { if tt.buildUserID != nil {
userID, deleteUserAfterTest = tt.buildUserID() userID = tt.buildUserID()
} else { } else {
createUserResp := Instance.CreateHumanUser(CTX) createUserResp := Instance.CreateHumanUser(CTX)
userID = createUserResp.UserId userID = createUserResp.UserId
} }
user, err := Instance.Client.SCIM.Users.Get(ctx, Instance.DefaultOrg.Id, userID) 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 { if tt.wantErr {
statusCode := tt.errorStatus statusCode := tt.errorStatus
if statusCode == 0 { if statusCode == 0 {
statusCode = http.StatusBadRequest statusCode = http.StatusBadRequest
} }
scim.RequireScimError(t, statusCode, err) scim.RequireScimError(ttt, statusCode, err)
return return
} }
assert.Equal(t, userID, user.ID) assert.Equal(ttt, userID, fetchedUser.ID)
assert.EqualValues(t, []schemas.ScimSchemaType{"urn:ietf:params:scim:schemas:core:2.0:User"}, user.Schemas) assert.EqualValues(ttt, []schemas.ScimSchemaType{"urn:ietf:params:scim:schemas:core:2.0:User"}, fetchedUser.Schemas)
assert.Equal(t, schemas.ScimResourceTypeSingular("User"), user.Resource.Meta.ResourceType) assert.Equal(ttt, schemas.ScimResourceTypeSingular("User"), fetchedUser.Resource.Meta.ResourceType)
assert.Equal(t, "http://"+Instance.Host()+path.Join(schemas.HandlerPrefix, Instance.DefaultOrg.Id, "Users", user.ID), user.Resource.Meta.Location) assert.Equal(ttt, "http://"+Instance.Host()+path.Join(schemas.HandlerPrefix, Instance.DefaultOrg.Id, "Users", fetchedUser.ID), fetchedUser.Resource.Meta.Location)
assert.Nil(t, user.Password) assert.Nil(ttt, fetchedUser.Password)
if !integration.PartiallyDeepEqual(tt.want, user) { if !integration.PartiallyDeepEqual(tt.want, fetchedUser) {
t.Errorf("keysFromArgs() got = %v, want %v", user, tt.want) ttt.Errorf("GetUser() got = %#v, want %#v", fetchedUser, tt.want)
} }
}, retryDuration, tick)
if deleteUserAfterTest { if tt.cleanup != nil {
_, err = Instance.Client.UserV2.DeleteUser(CTX, &guser.DeleteUserRequest{UserId: user.ID}) tt.cleanup(fetchedUser.ID)
require.NoError(t, err)
} }
}) })
} }

View 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)
}

View File

@ -19,6 +19,7 @@ type ResourceHandler[T ResourceHolder] interface {
NewResource() T NewResource() T
Create(ctx context.Context, resource T) (T, error) 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 Delete(ctx context.Context, id string) error
Get(ctx context.Context, id string) (T, error) Get(ctx context.Context, id string) (T, error)
} }

View File

@ -47,6 +47,16 @@ func (adapter *ResourceHandlerAdapter[T]) Create(r *http.Request) (T, error) {
return adapter.handler.Create(r.Context(), entity) 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 { func (adapter *ResourceHandlerAdapter[T]) Delete(r *http.Request) error {
id := mux.Vars(r)["id"] id := mux.Vars(r)["id"]
return adapter.handler.Delete(r.Context(), id) return adapter.handler.Delete(r.Context(), id)

View File

@ -140,8 +140,23 @@ func (h *UsersHandler) Create(ctx context.Context, user *ScimUser) (*ScimUser, e
return nil, err return nil, err
} }
user.ID = addHuman.Details.ID h.mapAddCommandToScimUser(ctx, user, addHuman)
user.Resource = buildResource(ctx, h, addHuman.Details) 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 return user, nil
} }

View File

@ -17,14 +17,22 @@ import (
) )
func (h *UsersHandler) mapToAddHuman(ctx context.Context, scimUser *ScimUser) (*command.AddHuman, error) { func (h *UsersHandler) mapToAddHuman(ctx context.Context, scimUser *ScimUser) (*command.AddHuman, error) {
// zitadel has its own state mechanism
// ignore scimUser.Active
human := &command.AddHuman{ human := &command.AddHuman{
Username: scimUser.UserName, Username: scimUser.UserName,
NickName: scimUser.NickName, NickName: scimUser.NickName,
DisplayName: scimUser.DisplayName, 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) md, err := h.mapMetadataToCommands(ctx, scimUser)
@ -46,6 +54,9 @@ func (h *UsersHandler) mapToAddHuman(ctx context.Context, scimUser *ScimUser) (*
// over the formatted name assignment // over the formatted name assignment
if human.DisplayName == "" { if human.DisplayName == "" {
human.DisplayName = scimUser.Name.Formatted 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 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 { for _, email := range scimUser.Emails {
if !email.Primary { if !email.Primary {
continue continue
} }
return command.Email{ return &command.Email{
Address: domain.EmailAddress(email.Value), Address: domain.EmailAddress(email.Value),
Verified: h.config.EmailVerified, 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 { for _, phone := range scimUser.PhoneNumbers {
if !phone.Primary { if !phone.Primary {
continue continue
} }
return command.Phone{ return &command.Phone{
Number: domain.PhoneNumber(phone.Value), Number: domain.PhoneNumber(phone.Value),
Verified: h.config.PhoneVerified, 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 { func (h *UsersHandler) mapToScimUser(ctx context.Context, user *query.User, md map[metadata.ScopedKey][]byte) *ScimUser {

View File

@ -12,6 +12,7 @@ import (
"github.com/zitadel/zitadel/internal/api/scim/schemas" "github.com/zitadel/zitadel/internal/api/scim/schemas"
"github.com/zitadel/zitadel/internal/api/scim/serrors" "github.com/zitadel/zitadel/internal/api/scim/serrors"
"github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/internal/zerrors" "github.com/zitadel/zitadel/internal/zerrors"
) )
@ -55,6 +56,28 @@ func buildMetadataKeyQuery(ctx context.Context, key metadata.Key) query.SearchQu
return q 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) { func (h *UsersHandler) mapMetadataToCommands(ctx context.Context, user *ScimUser) ([]*command.AddMetadataEntry, error) {
md := make([]*command.AddMetadataEntry, 0, len(metadata.ScimUserRelevantMetadataKeys)) md := make([]*command.AddMetadataEntry, 0, len(metadata.ScimUserRelevantMetadataKeys))
for _, key := range metadata.ScimUserRelevantMetadataKeys { for _, key := range metadata.ScimUserRelevantMetadataKeys {

View File

@ -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("", mw(handleResourceCreatedResponse(adapter.Create))).Methods(http.MethodPost)
resourceRouter.Handle("/{id}", mw(handleResourceResponse(adapter.Get))).Methods(http.MethodGet) 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) resourceRouter.Handle("/{id}", mw(handleEmptyResponse(adapter.Delete))).Methods(http.MethodDelete)
} }

View File

@ -59,6 +59,8 @@ type AddHuman struct {
Passwordless bool Passwordless bool
ExternalIDP bool ExternalIDP bool
Register 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. // 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. // This will be used in the login UI to handle authentication automatically.
UserAgentID string UserAgentID string

View File

@ -137,6 +137,10 @@ func isUserStateInactive(state domain.UserState) bool {
return hasUserState(state, domain.UserStateInactive) return hasUserState(state, domain.UserStateInactive)
} }
func isUserStateActive(state domain.UserState) bool {
return hasUserState(state, domain.UserStateActive)
}
func isUserStateInitial(state domain.UserState) bool { func isUserStateInitial(state domain.UserState) bool {
return hasUserState(state, domain.UserStateInitial) return hasUserState(state, domain.UserStateInitial)
} }

View File

@ -15,10 +15,13 @@ import (
type ChangeHuman struct { type ChangeHuman struct {
ID string ID string
State *domain.UserState
Username *string Username *string
Profile *Profile Profile *Profile
Email *Email Email *Email
Phone *Phone Phone *Phone
Metadata []*domain.Metadata
MetadataKeysToRemove []string
Password *Password Password *Password
@ -100,6 +103,15 @@ func (h *ChangeHuman) Changed() bool {
if h.Password != nil { if h.Password != nil {
return true return true
} }
if h.State != nil {
return true
}
if len(h.Metadata) > 0 {
return true
}
if len(h.MetadataKeysToRemove) > 0 {
return true
}
return false 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 { if len(cmds) == 0 {
human.Details = writeModelToObjectDetails(&existingHuman.WriteModel) human.Details = writeModelToObjectDetails(&existingHuman.WriteModel)
return nil 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) cmds := make([]eventstore.Command, 0)
if human.Username != nil { if human.Username != nil {
cmds, err = c.changeUsername(ctx, cmds, existingHuman, *human.Username) 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 { if len(cmds) == 0 {
human.Details = writeModelToObjectDetails(&existingHuman.WriteModel) human.Details = writeModelToObjectDetails(&existingHuman.WriteModel)
return nil return nil

View File

@ -171,9 +171,13 @@ func diffProto(expected, actual proto.Message) string {
return "\n\nDiff:\n" + diff 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] val, exists := m[key]
assert.True(t, exists, "Key '%s' should exist in the map", 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) 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 // Dereference pointers if needed
if expected.Kind() == reflect.Ptr { if expected.Kind() == reflect.Ptr {
if expected.IsNil() { if expected.IsNil() {
return actual.IsNil() return true
} }
expected = expected.Elem() expected = expected.Elem()

View File

@ -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)) 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) { func (c *ResourceClient[T]) Get(ctx context.Context, orgID, resourceID string) (*T, error) {
return c.doWithBody(ctx, http.MethodGet, orgID, resourceID, nil) return c.doWithBody(ctx, http.MethodGet, orgID, resourceID, nil)
} }