mirror of
https://github.com/zitadel/zitadel.git
synced 2025-08-12 00:27:31 +00:00
feat: patch user scim v2 endpoint (#9219)
# Which Problems Are Solved * Adds support for the patch user SCIM v2 endpoint # How the Problems Are Solved * Adds support for the patch user SCIM v2 endpoint under `PATCH /scim/v2/{orgID}/Users/{id}` # Additional Context Part of #8140
This commit is contained in:
139
internal/api/scim/integration_test/testdata/users_update_test_full.json
vendored
Normal file
139
internal/api/scim/integration_test/testdata/users_update_test_full.json
vendored
Normal file
@@ -0,0 +1,139 @@
|
||||
{
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
||||
"Operations": [
|
||||
// add without path
|
||||
{
|
||||
"op": "add",
|
||||
"value": {
|
||||
"emails":[
|
||||
{
|
||||
"value":"babs@example.com",
|
||||
"type":"home",
|
||||
"primary": true
|
||||
}
|
||||
],
|
||||
"nickname":"added-babs"
|
||||
}
|
||||
},
|
||||
// add complex attribute with path
|
||||
{
|
||||
"op": "add",
|
||||
"path": "name",
|
||||
"value": {
|
||||
"formatted": "added-formatted",
|
||||
"familyName": "added-family-name",
|
||||
"givenName": "added-given-name",
|
||||
"middleName": "added-middle-name",
|
||||
"honorificPrefix": "added-honorific-prefix",
|
||||
"honorificSuffix": "added-honorific-suffix"
|
||||
}
|
||||
},
|
||||
// add complex attribute value
|
||||
{
|
||||
"op": "add",
|
||||
"path": "name.middlename",
|
||||
"value": "added-middle-name-2"
|
||||
},
|
||||
// add single to multi valued attribute
|
||||
{
|
||||
"op": "add",
|
||||
"path": "entitlements",
|
||||
"value": { "value": "added-entitlement-1" }
|
||||
},
|
||||
// add single already existing to multi valued attribute
|
||||
// (should be replaced)
|
||||
{
|
||||
"op": "add",
|
||||
"path": "entitlements",
|
||||
"value": {
|
||||
"value": "my-entitlement-1",
|
||||
"display": "added-entitlement-1",
|
||||
"type": "added-entitlement-1",
|
||||
"primary": true
|
||||
}
|
||||
},
|
||||
// add multiple to multi valued attribute,
|
||||
// with one item already existing (should be replaced)
|
||||
{
|
||||
"op": "add",
|
||||
"path": "entitlements",
|
||||
"value": [
|
||||
{ "value": "added-entitlement-2" },
|
||||
{ "value": "added-entitlement-3", "primary": true }
|
||||
]
|
||||
},
|
||||
// remove single valued attribute
|
||||
{
|
||||
"op": "remove",
|
||||
"path": "nickname"
|
||||
},
|
||||
// remove multi valued attribute
|
||||
{
|
||||
"op": "remove",
|
||||
"path": "roles"
|
||||
},
|
||||
// remove multi valued attribute with filter
|
||||
{
|
||||
"op": "remove",
|
||||
"path": "photos[type eq \"thumbnail\"]"
|
||||
},
|
||||
// remove attribute of multi valued attribute with filter
|
||||
{
|
||||
"op": "remove",
|
||||
"path": "ims[type eq \"X\"].type"
|
||||
},
|
||||
// remove multi valued attribute with non-matching filter
|
||||
{
|
||||
"op": "remove",
|
||||
"path": "ims[type eq \"fooBar\"].type"
|
||||
},
|
||||
// replace without path
|
||||
{
|
||||
"op": "replace",
|
||||
"value": {
|
||||
"displayName": "replaced-display-name"
|
||||
}
|
||||
},
|
||||
// replace nested with path
|
||||
{
|
||||
"op": "replace",
|
||||
"path": "name.honorificSuffix",
|
||||
"value": "replaced-honorific-suffix"
|
||||
},
|
||||
// replace complex multi attribute
|
||||
{
|
||||
"op": "replace",
|
||||
"path": "addresses",
|
||||
"value": [
|
||||
{
|
||||
"type": "replaced-work",
|
||||
"streetAddress": "replaced-100 Universal City Plaza",
|
||||
"locality": "replaced-Hollywood",
|
||||
"region": "replaced-CA",
|
||||
"postalCode": "replaced-91608",
|
||||
"country": "replaced-USA",
|
||||
"formatted": "replaced-100 Universal City Plaza\nHollywood, CA 91608 USA",
|
||||
"primary": true
|
||||
}
|
||||
]
|
||||
},
|
||||
// replace phone
|
||||
{
|
||||
"op": "replace",
|
||||
"path": "phonenumbers[primary eq true].value",
|
||||
"value": "+41711234567"
|
||||
},
|
||||
// replace externalID
|
||||
{
|
||||
"op": "replace",
|
||||
"path": "externalid",
|
||||
"value": "fooBAR"
|
||||
},
|
||||
// replace password
|
||||
{
|
||||
"op": "replace",
|
||||
"path": "password",
|
||||
"value": "Password2!"
|
||||
}
|
||||
]
|
||||
}
|
@@ -265,6 +265,7 @@ func TestCreateUser(t *testing.T) {
|
||||
createdUser, err := Instance.Client.SCIM.Users.Create(ctx, Instance.DefaultOrg.Id, tt.body)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("CreateUser() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
|
@@ -233,6 +233,7 @@ func TestReplaceUser(t *testing.T) {
|
||||
if statusCode == 0 {
|
||||
statusCode = http.StatusBadRequest
|
||||
}
|
||||
|
||||
scimErr := scim.RequireScimError(t, statusCode, err)
|
||||
assert.Equal(t, tt.scimErrorType, scimErr.Error.ScimType)
|
||||
if tt.zitadelErrID != "" {
|
||||
|
281
internal/api/scim/integration_test/users_update_test.go
Normal file
281
internal/api/scim/integration_test/users_update_test.go
Normal file
@@ -0,0 +1,281 @@
|
||||
//go:build integration
|
||||
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/brianvoe/gofakeit/v6"
|
||||
"github.com/muhlemmer/gu"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/text/language"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/scim/resources"
|
||||
"github.com/zitadel/zitadel/internal/api/scim/schemas"
|
||||
"github.com/zitadel/zitadel/internal/integration"
|
||||
"github.com/zitadel/zitadel/internal/integration/scim"
|
||||
"github.com/zitadel/zitadel/internal/test"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/user/v2"
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed testdata/users_update_test_full.json
|
||||
fullUserUpdateJson []byte
|
||||
|
||||
minimalUserUpdateJson []byte = simpleReplacePatchBody("nickname", "foo")
|
||||
|
||||
// remove comments in the json, as the default golang json unmarshaler cannot handle them
|
||||
// the test file is much easier to maintain with comments
|
||||
removeCommentsRegex = regexp.MustCompile("(?s)//.*?\n|/\\*.*?\\*/")
|
||||
)
|
||||
|
||||
func init() {
|
||||
fullUserUpdateJson = removeComments(fullUserUpdateJson)
|
||||
}
|
||||
|
||||
func removeComments(json []byte) []byte {
|
||||
return removeCommentsRegex.ReplaceAll(json, nil)
|
||||
}
|
||||
|
||||
func TestUpdateUser(t *testing.T) {
|
||||
fullUserCreated, 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: fullUserCreated.ID})
|
||||
require.NoError(t, err)
|
||||
}()
|
||||
|
||||
iamOwnerCtx := Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner)
|
||||
secondaryOrg := Instance.CreateOrganization(iamOwnerCtx, gofakeit.Name(), gofakeit.Email())
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
body []byte
|
||||
ctx context.Context
|
||||
orgID string
|
||||
userID string
|
||||
want *resources.ScimUser
|
||||
wantErr bool
|
||||
scimErrorType string
|
||||
errorStatus int
|
||||
}{
|
||||
{
|
||||
name: "not authenticated",
|
||||
ctx: context.Background(),
|
||||
body: minimalUserUpdateJson,
|
||||
wantErr: true,
|
||||
errorStatus: http.StatusUnauthorized,
|
||||
},
|
||||
{
|
||||
name: "no permissions",
|
||||
ctx: Instance.WithAuthorization(CTX, integration.UserTypeNoPermission),
|
||||
body: minimalUserUpdateJson,
|
||||
wantErr: true,
|
||||
errorStatus: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
name: "other org",
|
||||
orgID: secondaryOrg.OrganizationId,
|
||||
body: minimalUserUpdateJson,
|
||||
wantErr: true,
|
||||
errorStatus: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
name: "invalid patch json",
|
||||
body: simpleReplacePatchBody("nickname", "10"),
|
||||
wantErr: true,
|
||||
scimErrorType: "invalidValue",
|
||||
},
|
||||
{
|
||||
name: "password complexity violation",
|
||||
body: simpleReplacePatchBody("password", `"fooBar"`),
|
||||
wantErr: true,
|
||||
scimErrorType: "invalidValue",
|
||||
},
|
||||
{
|
||||
name: "invalid profile url",
|
||||
body: simpleReplacePatchBody("profileUrl", `"ftp://example.com/profiles"`),
|
||||
wantErr: true,
|
||||
scimErrorType: "invalidValue",
|
||||
},
|
||||
{
|
||||
name: "invalid time zone",
|
||||
body: simpleReplacePatchBody("timezone", `"foobar"`),
|
||||
wantErr: true,
|
||||
scimErrorType: "invalidValue",
|
||||
},
|
||||
{
|
||||
name: "invalid locale",
|
||||
body: simpleReplacePatchBody("locale", `"foobar"`),
|
||||
wantErr: true,
|
||||
scimErrorType: "invalidValue",
|
||||
},
|
||||
{
|
||||
name: "unknown user id",
|
||||
body: simpleReplacePatchBody("nickname", `"foo"`),
|
||||
userID: "fooBar",
|
||||
wantErr: true,
|
||||
errorStatus: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
name: "full",
|
||||
body: fullUserUpdateJson,
|
||||
want: &resources.ScimUser{
|
||||
ExternalID: "fooBAR",
|
||||
UserName: "bjensen@example.com",
|
||||
Name: &resources.ScimUserName{
|
||||
Formatted: "replaced-display-name",
|
||||
FamilyName: "added-family-name",
|
||||
GivenName: "added-given-name",
|
||||
MiddleName: "added-middle-name-2",
|
||||
HonorificPrefix: "added-honorific-prefix",
|
||||
HonorificSuffix: "replaced-honorific-suffix",
|
||||
},
|
||||
DisplayName: "replaced-display-name",
|
||||
NickName: "",
|
||||
ProfileUrl: test.Must(schemas.ParseHTTPURL("http://login.example.com/bjensen")),
|
||||
Emails: []*resources.ScimEmail{
|
||||
{
|
||||
Value: "babs@example.com",
|
||||
Primary: true,
|
||||
},
|
||||
},
|
||||
Addresses: []*resources.ScimAddress{
|
||||
{
|
||||
Type: "replaced-work",
|
||||
StreetAddress: "replaced-100 Universal City Plaza",
|
||||
Locality: "replaced-Hollywood",
|
||||
Region: "replaced-CA",
|
||||
PostalCode: "replaced-91608",
|
||||
Country: "replaced-USA",
|
||||
Formatted: "replaced-100 Universal City Plaza\nHollywood, CA 91608 USA",
|
||||
Primary: true,
|
||||
},
|
||||
},
|
||||
PhoneNumbers: []*resources.ScimPhoneNumber{
|
||||
{
|
||||
Value: "+41711234567",
|
||||
Primary: true,
|
||||
},
|
||||
},
|
||||
Ims: []*resources.ScimIms{
|
||||
{
|
||||
Value: "someaimhandle",
|
||||
Type: "aim",
|
||||
},
|
||||
{
|
||||
Value: "twitterhandle",
|
||||
Type: "",
|
||||
},
|
||||
},
|
||||
Photos: []*resources.ScimPhoto{
|
||||
{
|
||||
Value: *test.Must(schemas.ParseHTTPURL("https://photos.example.com/profilephoto/72930000000Ccne/F")),
|
||||
Type: "photo",
|
||||
},
|
||||
},
|
||||
Roles: nil,
|
||||
Entitlements: []*resources.ScimEntitlement{
|
||||
{
|
||||
Value: "my-entitlement-1",
|
||||
Display: "added-entitlement-1",
|
||||
Type: "added-entitlement-1",
|
||||
Primary: false,
|
||||
},
|
||||
{
|
||||
Value: "my-entitlement-2",
|
||||
Display: "Entitlement 2",
|
||||
Type: "secondary-entitlement",
|
||||
Primary: false,
|
||||
},
|
||||
{
|
||||
Value: "added-entitlement-1",
|
||||
Primary: false,
|
||||
},
|
||||
{
|
||||
Value: "added-entitlement-2",
|
||||
Primary: false,
|
||||
},
|
||||
{
|
||||
Value: "added-entitlement-3",
|
||||
Primary: true,
|
||||
},
|
||||
},
|
||||
Title: "Tour Guide",
|
||||
PreferredLanguage: language.MustParse("en-US"),
|
||||
Locale: "en-US",
|
||||
Timezone: "America/Los_Angeles",
|
||||
Active: gu.Ptr(true),
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.ctx == nil {
|
||||
tt.ctx = CTX
|
||||
}
|
||||
|
||||
if tt.orgID == "" {
|
||||
tt.orgID = Instance.DefaultOrg.Id
|
||||
}
|
||||
|
||||
if tt.userID == "" {
|
||||
tt.userID = fullUserCreated.ID
|
||||
}
|
||||
|
||||
err := Instance.Client.SCIM.Users.Update(tt.ctx, tt.orgID, tt.userID, tt.body)
|
||||
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
|
||||
statusCode := tt.errorStatus
|
||||
if statusCode == 0 {
|
||||
statusCode = http.StatusBadRequest
|
||||
}
|
||||
|
||||
scimErr := scim.RequireScimError(t, statusCode, err)
|
||||
assert.Equal(t, tt.scimErrorType, scimErr.Error.ScimType)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
|
||||
retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Minute)
|
||||
require.EventuallyWithT(t, func(ttt *assert.CollectT) {
|
||||
fetchedUser, err := Instance.Client.SCIM.Users.Get(tt.ctx, tt.orgID, fullUserCreated.ID)
|
||||
require.NoError(ttt, err)
|
||||
|
||||
fetchedUser.Resource = nil
|
||||
fetchedUser.ID = ""
|
||||
if tt.want != nil && !test.PartiallyDeepEqual(tt.want, fetchedUser) {
|
||||
ttt.Errorf("got = %#v, want = %#v", fetchedUser, tt.want)
|
||||
}
|
||||
}, retryDuration, tick)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func simpleReplacePatchBody(path, value string) []byte {
|
||||
return []byte(fmt.Sprintf(
|
||||
`{
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
||||
"Operations": [
|
||||
{
|
||||
"op": "replace",
|
||||
"path": "%s",
|
||||
"value": %s
|
||||
}
|
||||
]
|
||||
}`,
|
||||
path,
|
||||
value,
|
||||
))
|
||||
}
|
Reference in New Issue
Block a user