diff --git a/internal/api/scim/authz.go b/internal/api/scim/authz.go index 26b4ecf10..1ab174e7b 100644 --- a/internal/api/scim/authz.go +++ b/internal/api/scim/authz.go @@ -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, }, diff --git a/internal/api/scim/integration_test/testdata/users_create_test_minimal_inactive.json b/internal/api/scim/integration_test/testdata/users_create_test_minimal_inactive.json new file mode 100644 index 000000000..11650674a --- /dev/null +++ b/internal/api/scim/integration_test/testdata/users_create_test_minimal_inactive.json @@ -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 +} \ No newline at end of file diff --git a/internal/api/scim/integration_test/testdata/users_replace_test_full.json b/internal/api/scim/integration_test/testdata/users_replace_test_full.json new file mode 100644 index 000000000..83ff72b69 --- /dev/null +++ b/internal/api/scim/integration_test/testdata/users_replace_test_full.json @@ -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" +} \ No newline at end of file diff --git a/internal/api/scim/integration_test/testdata/users_replace_test_minimal.json b/internal/api/scim/integration_test/testdata/users_replace_test_minimal.json new file mode 100644 index 000000000..f8756bf4a --- /dev/null +++ b/internal/api/scim/integration_test/testdata/users_replace_test_minimal.json @@ -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 + } + ] +} \ No newline at end of file diff --git a/internal/api/scim/integration_test/testdata/users_replace_test_minimal_with_external_id.json b/internal/api/scim/integration_test/testdata/users_replace_test_minimal_with_external_id.json new file mode 100644 index 000000000..d02e60597 --- /dev/null +++ b/internal/api/scim/integration_test/testdata/users_replace_test_minimal_with_external_id.json @@ -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 + } + ] +} \ No newline at end of file diff --git a/internal/api/scim/integration_test/users_create_test.go b/internal/api/scim/integration_test/users_create_test.go index b7d97e342..1a3e2b8dd 100644 --- a/internal/api/scim/integration_test/users_create_test.go +++ b/internal/api/scim/integration_test/users_create_test.go @@ -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) { diff --git a/internal/api/scim/integration_test/users_delete_test.go b/internal/api/scim/integration_test/users_delete_test.go index 6d3f73a71..88c7bf88e 100644 --- a/internal/api/scim/integration_test/users_delete_test.go +++ b/internal/api/scim/integration_test/users_delete_test.go @@ -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) { diff --git a/internal/api/scim/integration_test/users_get_test.go b/internal/api/scim/integration_test/users_get_test.go index 4506de1ec..0790b591c 100644 --- a/internal/api/scim/integration_test/users_get_test.go +++ b/internal/api/scim/integration_test/users_get_test.go @@ -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) } }) } diff --git a/internal/api/scim/integration_test/users_replace_test.go b/internal/api/scim/integration_test/users_replace_test.go new file mode 100644 index 000000000..664364bbe --- /dev/null +++ b/internal/api/scim/integration_test/users_replace_test.go @@ -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) +} diff --git a/internal/api/scim/resources/resource_handler.go b/internal/api/scim/resources/resource_handler.go index 93cb9adca..4e1d9c1d4 100644 --- a/internal/api/scim/resources/resource_handler.go +++ b/internal/api/scim/resources/resource_handler.go @@ -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) } diff --git a/internal/api/scim/resources/resource_handler_adapter.go b/internal/api/scim/resources/resource_handler_adapter.go index bb362e9df..5a346911a 100644 --- a/internal/api/scim/resources/resource_handler_adapter.go +++ b/internal/api/scim/resources/resource_handler_adapter.go @@ -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) diff --git a/internal/api/scim/resources/user.go b/internal/api/scim/resources/user.go index 5c9b35263..defe84953 100644 --- a/internal/api/scim/resources/user.go +++ b/internal/api/scim/resources/user.go @@ -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 } diff --git a/internal/api/scim/resources/user_mapping.go b/internal/api/scim/resources/user_mapping.go index 9726f1e19..4de826ca6 100644 --- a/internal/api/scim/resources/user_mapping.go +++ b/internal/api/scim/resources/user_mapping.go @@ -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 { diff --git a/internal/api/scim/resources/user_metadata.go b/internal/api/scim/resources/user_metadata.go index f094695a2..1bb00ff8a 100644 --- a/internal/api/scim/resources/user_metadata.go +++ b/internal/api/scim/resources/user_metadata.go @@ -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 { diff --git a/internal/api/scim/server.go b/internal/api/scim/server.go index 2a4385e21..d5d739bdc 100644 --- a/internal/api/scim/server.go +++ b/internal/api/scim/server.go @@ -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) } diff --git a/internal/command/user_human.go b/internal/command/user_human.go index ab2617c27..9e6ba4362 100644 --- a/internal/command/user_human.go +++ b/internal/command/user_human.go @@ -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 diff --git a/internal/command/user_model.go b/internal/command/user_model.go index 0e68f0812..d600f9d98 100644 --- a/internal/command/user_model.go +++ b/internal/command/user_model.go @@ -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) } diff --git a/internal/command/user_v2_human.go b/internal/command/user_v2_human.go index a85f905e0..fa627ec66 100644 --- a/internal/command/user_v2_human.go +++ b/internal/command/user_v2_human.go @@ -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 diff --git a/internal/integration/assert.go b/internal/integration/assert.go index 40c681519..de35357dd 100644 --- a/internal/integration/assert.go +++ b/internal/integration/assert.go @@ -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() diff --git a/internal/integration/scim/client.go b/internal/integration/scim/client.go index 2a6f106b9..262835a82 100644 --- a/internal/integration/scim/client.go +++ b/internal/integration/scim/client.go @@ -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) }