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}": {
Permission: domain.PermissionUserRead,
},
"PUT:/scim/v2/" + http.OrgIdInPathVariable + "/Users/{id}": {
Permission: domain.PermissionUserWrite,
},
"DELETE:/scim/v2/" + http.OrgIdInPathVariable + "/Users/{id}": {
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"
_ "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) {

View File

@ -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) {

View File

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

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

View File

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

View File

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

View File

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

View File

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

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("/{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)
}

View File

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

View File

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

View File

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

View File

@ -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()

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