mirror of
https://github.com/zitadel/zitadel.git
synced 2025-07-16 22:39:15 +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:
parent
ec5f18c168
commit
189f9770c6
@ -22,6 +22,9 @@ var AuthMapping = authz.MethodMapping{
|
|||||||
"PUT:/scim/v2/" + http.OrgIdInPathVariable + "/Users/{id}": {
|
"PUT:/scim/v2/" + http.OrgIdInPathVariable + "/Users/{id}": {
|
||||||
Permission: domain.PermissionUserWrite,
|
Permission: domain.PermissionUserWrite,
|
||||||
},
|
},
|
||||||
|
"PATCH:/scim/v2/" + http.OrgIdInPathVariable + "/Users/{id}": {
|
||||||
|
Permission: domain.PermissionUserWrite,
|
||||||
|
},
|
||||||
"DELETE:/scim/v2/" + http.OrgIdInPathVariable + "/Users/{id}": {
|
"DELETE:/scim/v2/" + http.OrgIdInPathVariable + "/Users/{id}": {
|
||||||
Permission: domain.PermissionUserDelete,
|
Permission: domain.PermissionUserDelete,
|
||||||
},
|
},
|
||||||
|
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)
|
createdUser, err := Instance.Client.SCIM.Users.Create(ctx, Instance.DefaultOrg.Id, tt.body)
|
||||||
if (err != nil) != tt.wantErr {
|
if (err != nil) != tt.wantErr {
|
||||||
t.Errorf("CreateUser() error = %v, wantErr %v", err, tt.wantErr)
|
t.Errorf("CreateUser() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -233,6 +233,7 @@ func TestReplaceUser(t *testing.T) {
|
|||||||
if statusCode == 0 {
|
if statusCode == 0 {
|
||||||
statusCode = http.StatusBadRequest
|
statusCode = http.StatusBadRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
scimErr := scim.RequireScimError(t, statusCode, err)
|
scimErr := scim.RequireScimError(t, statusCode, err)
|
||||||
assert.Equal(t, tt.scimErrorType, scimErr.Error.ScimType)
|
assert.Equal(t, tt.scimErrorType, scimErr.Error.ScimType)
|
||||||
if tt.zitadelErrID != "" {
|
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,
|
||||||
|
))
|
||||||
|
}
|
@ -3,6 +3,8 @@ package metadata
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/zitadel/zitadel/internal/query"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Key string
|
type Key string
|
||||||
@ -30,21 +32,40 @@ const (
|
|||||||
KeyRoles Key = KeyPrefix + "roles"
|
KeyRoles Key = KeyPrefix + "roles"
|
||||||
)
|
)
|
||||||
|
|
||||||
var ScimUserRelevantMetadataKeys = []Key{
|
var (
|
||||||
KeyExternalId,
|
ScimUserRelevantMetadataKeys = []Key{
|
||||||
KeyMiddleName,
|
KeyExternalId,
|
||||||
KeyHonorificPrefix,
|
KeyMiddleName,
|
||||||
KeyHonorificSuffix,
|
KeyHonorificPrefix,
|
||||||
KeyProfileUrl,
|
KeyHonorificSuffix,
|
||||||
KeyTitle,
|
KeyProfileUrl,
|
||||||
KeyLocale,
|
KeyTitle,
|
||||||
KeyTimezone,
|
KeyLocale,
|
||||||
KeyIms,
|
KeyTimezone,
|
||||||
KeyPhotos,
|
KeyIms,
|
||||||
KeyAddresses,
|
KeyPhotos,
|
||||||
KeyEntitlements,
|
KeyAddresses,
|
||||||
KeyRoles,
|
KeyEntitlements,
|
||||||
}
|
KeyRoles,
|
||||||
|
}
|
||||||
|
|
||||||
|
AttributePathToMetadataKeys = map[string][]Key{
|
||||||
|
"externalid": {KeyExternalId},
|
||||||
|
"name": {KeyMiddleName, KeyHonorificPrefix, KeyHonorificSuffix},
|
||||||
|
"name.middlename": {KeyMiddleName},
|
||||||
|
"name.honorificprefix": {KeyHonorificPrefix},
|
||||||
|
"name.honorificsuffix": {KeyHonorificSuffix},
|
||||||
|
"profileurl": {KeyProfileUrl},
|
||||||
|
"title": {KeyTitle},
|
||||||
|
"locale": {KeyLocale},
|
||||||
|
"timezone": {KeyTimezone},
|
||||||
|
"ims": {KeyIms},
|
||||||
|
"photos": {KeyPhotos},
|
||||||
|
"addresses": {KeyAddresses},
|
||||||
|
"entitlements": {KeyEntitlements},
|
||||||
|
"roles": {KeyRoles},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
func ScopeExternalIdKey(provisioningDomain string) ScopedKey {
|
func ScopeExternalIdKey(provisioningDomain string) ScopedKey {
|
||||||
return ScopedKey(strings.Replace(keyScopedExternalIdTemplate, externalIdProvisioningDomainPlaceholder, provisioningDomain, 1))
|
return ScopedKey(strings.Replace(keyScopedExternalIdTemplate, externalIdProvisioningDomainPlaceholder, provisioningDomain, 1))
|
||||||
@ -58,3 +79,21 @@ func ScopeKey(ctx context.Context, key Key) ScopedKey {
|
|||||||
|
|
||||||
return ScopedKey(key)
|
return ScopedKey(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func MapToScopedKeyMap(md map[string][]byte) map[ScopedKey][]byte {
|
||||||
|
result := make(map[ScopedKey][]byte, len(md))
|
||||||
|
for k, v := range md {
|
||||||
|
result[ScopedKey(k)] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func MapListToScopedKeyMap(metadataList []*query.UserMetadata) map[ScopedKey][]byte {
|
||||||
|
metadataMap := make(map[ScopedKey][]byte, len(metadataList))
|
||||||
|
for _, entry := range metadataList {
|
||||||
|
metadataMap[ScopedKey(entry.Key)] = entry.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadataMap
|
||||||
|
}
|
||||||
|
110
internal/api/scim/resources/filter/attribute_resolver.go
Normal file
110
internal/api/scim/resources/filter/attribute_resolver.go
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
package filter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/zitadel/zitadel/internal/api/scim/schemas"
|
||||||
|
"github.com/zitadel/zitadel/internal/zerrors"
|
||||||
|
)
|
||||||
|
|
||||||
|
// jsonFieldCache Cache storing JSON tag to field mappings for each reflect.Type
|
||||||
|
// keep this in memory forever, as the fields / json tags are constant at runtime and never change.
|
||||||
|
// reflect.Type => structFieldCache
|
||||||
|
var jsonFieldCache sync.Map
|
||||||
|
|
||||||
|
type structFieldCache map[string]reflect.StructField
|
||||||
|
|
||||||
|
type AttributeResolver struct {
|
||||||
|
schema schemas.ScimSchemaType
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c structFieldCache) get(name string) (reflect.StructField, error) {
|
||||||
|
field, ok := c[strings.ToLower(name)]
|
||||||
|
if !ok {
|
||||||
|
return reflect.StructField{}, zerrors.ThrowInvalidArgumentf(nil, "SCIM-attr12", "SCIM Attribute not found %s", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return field, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c structFieldCache) set(field reflect.StructField) {
|
||||||
|
jsonTag := field.Tag.Get("json")
|
||||||
|
|
||||||
|
// Skip fields explicitly excluded
|
||||||
|
if jsonTag == "-" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// use field name as default
|
||||||
|
fieldName := field.Name
|
||||||
|
if jsonTag != "" {
|
||||||
|
// strip other options such as omitempty
|
||||||
|
fieldName = strings.Split(jsonTag, ",")[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
c[strings.ToLower(fieldName)] = field
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAttributeResolver(schema schemas.ScimSchemaType) *AttributeResolver {
|
||||||
|
return &AttributeResolver{
|
||||||
|
schema: schema,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AttributeResolver) resolveAttrPath(item reflect.Value, attrPath *AttrPath) ([]string, reflect.Value, error) {
|
||||||
|
if err := attrPath.validateSchema(r.schema); err != nil {
|
||||||
|
return nil, reflect.Value{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
segments := attrPath.Segments()
|
||||||
|
for _, segment := range segments {
|
||||||
|
var err error
|
||||||
|
item, err = r.resolveField(item, segment)
|
||||||
|
if err != nil {
|
||||||
|
return nil, reflect.Value{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments, item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AttributeResolver) resolveField(item reflect.Value, fieldName string) (reflect.Value, error) {
|
||||||
|
if item.Kind() == reflect.Ptr {
|
||||||
|
item = item.Elem()
|
||||||
|
}
|
||||||
|
|
||||||
|
fields, err := r.getOrBuildFieldMap(item.Type())
|
||||||
|
if err != nil {
|
||||||
|
return reflect.Value{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
field, err := fields.get(fieldName)
|
||||||
|
if err != nil {
|
||||||
|
return reflect.Value{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resolvedField := item.FieldByName(field.Name)
|
||||||
|
if !resolvedField.IsValid() {
|
||||||
|
return reflect.Value{}, zerrors.ThrowInvalidArgumentf(nil, "SCIM-attr13", "SCIM Attribute not found %s", fieldName)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolvedField, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AttributeResolver) getOrBuildFieldMap(t reflect.Type) (structFieldCache, error) {
|
||||||
|
if cached, ok := jsonFieldCache.Load(t); ok {
|
||||||
|
return cached.(structFieldCache), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cache miss, build json name field map
|
||||||
|
fieldMap := make(structFieldCache, t.NumField())
|
||||||
|
for i := 0; i < t.NumField(); i++ {
|
||||||
|
fieldMap.set(t.Field(i))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the result for future use.
|
||||||
|
jsonFieldCache.Store(t, fieldMap)
|
||||||
|
return fieldMap, nil
|
||||||
|
}
|
137
internal/api/scim/resources/filter/attribute_resolver_test.go
Normal file
137
internal/api/scim/resources/filter/attribute_resolver_test.go
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
package filter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/muhlemmer/gu"
|
||||||
|
|
||||||
|
"github.com/zitadel/zitadel/internal/api/scim/schemas"
|
||||||
|
)
|
||||||
|
|
||||||
|
type attributeResolverTestType struct {
|
||||||
|
StringValue string
|
||||||
|
Nested struct {
|
||||||
|
IntValue int
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAttributeResolver_resolveAttrPath(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
schema schemas.ScimSchemaType
|
||||||
|
item interface{}
|
||||||
|
attrPath *AttrPath
|
||||||
|
wantSegments []string
|
||||||
|
wantValue interface{}
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "simple",
|
||||||
|
schema: "fooBar",
|
||||||
|
item: &attributeResolverTestType{StringValue: "value"},
|
||||||
|
attrPath: &AttrPath{
|
||||||
|
AttrName: "StringValue",
|
||||||
|
},
|
||||||
|
wantSegments: []string{"stringvalue"},
|
||||||
|
wantValue: "value",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "simple case insensitive",
|
||||||
|
schema: "fooBar",
|
||||||
|
item: &attributeResolverTestType{StringValue: "value"},
|
||||||
|
attrPath: &AttrPath{
|
||||||
|
AttrName: "stringvalue",
|
||||||
|
},
|
||||||
|
wantSegments: []string{"stringvalue"},
|
||||||
|
wantValue: "value",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid attribute",
|
||||||
|
schema: "fooBar",
|
||||||
|
item: &attributeResolverTestType{StringValue: "value"},
|
||||||
|
attrPath: &AttrPath{
|
||||||
|
AttrName: "StringValue2",
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with SubAttr",
|
||||||
|
schema: "fooBar",
|
||||||
|
item: &attributeResolverTestType{Nested: struct{ IntValue int }{IntValue: 42}},
|
||||||
|
attrPath: &AttrPath{
|
||||||
|
AttrName: "Nested",
|
||||||
|
SubAttr: gu.Ptr("IntValue"),
|
||||||
|
},
|
||||||
|
wantSegments: []string{"nested", "intvalue"},
|
||||||
|
wantValue: 42,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with case-insensitive SubAttr",
|
||||||
|
schema: "fooBar",
|
||||||
|
item: &attributeResolverTestType{Nested: struct{ IntValue int }{IntValue: 42}},
|
||||||
|
attrPath: &AttrPath{
|
||||||
|
AttrName: "NESTED",
|
||||||
|
SubAttr: gu.Ptr("intvalue"),
|
||||||
|
},
|
||||||
|
wantSegments: []string{"nested", "intvalue"},
|
||||||
|
wantValue: 42,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with invalid SubAttr",
|
||||||
|
schema: "fooBar",
|
||||||
|
item: &attributeResolverTestType{Nested: struct{ IntValue int }{IntValue: 42}},
|
||||||
|
attrPath: &AttrPath{
|
||||||
|
AttrName: "NESTED",
|
||||||
|
SubAttr: gu.Ptr("xxxxx"),
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with SubAttr and urn",
|
||||||
|
schema: "fooBar",
|
||||||
|
item: &attributeResolverTestType{Nested: struct{ IntValue int }{IntValue: 42}},
|
||||||
|
attrPath: &AttrPath{
|
||||||
|
UrnAttributePrefix: gu.Ptr("fooBar:"),
|
||||||
|
AttrName: "Nested",
|
||||||
|
SubAttr: gu.Ptr("IntValue"),
|
||||||
|
},
|
||||||
|
wantSegments: []string{"nested", "intvalue"},
|
||||||
|
wantValue: 42,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with SubAttr and invalid urn",
|
||||||
|
schema: "fooBar",
|
||||||
|
item: &attributeResolverTestType{Nested: struct{ IntValue int }{IntValue: 42}},
|
||||||
|
attrPath: &AttrPath{
|
||||||
|
UrnAttributePrefix: gu.Ptr("fooBaz:"),
|
||||||
|
AttrName: "Nested",
|
||||||
|
SubAttr: gu.Ptr("IntValue"),
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
r := newAttributeResolver(tt.schema)
|
||||||
|
|
||||||
|
gotSegments, gotValue, err := r.resolveAttrPath(reflect.ValueOf(tt.item), tt.attrPath)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("resolveAttrPath() error = %#v, wantErr %#v", err, tt.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !reflect.DeepEqual(gotSegments, tt.wantSegments) {
|
||||||
|
t.Errorf("resolveAttrPath() gotSegments = %#v, want %#v", gotSegments, tt.wantSegments)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !reflect.DeepEqual(gotValue.Interface(), tt.wantValue) {
|
||||||
|
t.Errorf("resolveAttrPath() gotValue = %#v, want %#v", gotValue, tt.wantValue)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
338
internal/api/scim/resources/filter/filter_evaluator.go
Normal file
338
internal/api/scim/resources/filter/filter_evaluator.go
Normal file
@ -0,0 +1,338 @@
|
|||||||
|
package filter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/zitadel/logging"
|
||||||
|
|
||||||
|
"github.com/zitadel/zitadel/internal/api/scim/schemas"
|
||||||
|
"github.com/zitadel/zitadel/internal/zerrors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Evaluator struct {
|
||||||
|
schema schemas.ScimSchemaType
|
||||||
|
attributeResolver *AttributeResolver
|
||||||
|
}
|
||||||
|
|
||||||
|
type SimpleValueEvaluationResult struct {
|
||||||
|
PathSegments []string
|
||||||
|
Value reflect.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
type FilteredValuesEvaluationResult struct {
|
||||||
|
PathSegments []string
|
||||||
|
Source reflect.Value
|
||||||
|
Matches []*FilteredValuesEvaluationResultMatch
|
||||||
|
}
|
||||||
|
|
||||||
|
type FilteredValuesEvaluationResultMatch struct {
|
||||||
|
// Value the selected value of the Element. Can be the Element itself or an attribute of the Element.
|
||||||
|
Value reflect.Value
|
||||||
|
Element reflect.Value
|
||||||
|
SourceIndex int
|
||||||
|
}
|
||||||
|
|
||||||
|
type EvaluationResult interface{}
|
||||||
|
|
||||||
|
func NewEvaluator(schema schemas.ScimSchemaType) *Evaluator {
|
||||||
|
return &Evaluator{
|
||||||
|
schema: schema,
|
||||||
|
attributeResolver: newAttributeResolver(schema),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Evaluator) Evaluate(v reflect.Value, path *Path) (EvaluationResult, error) {
|
||||||
|
switch {
|
||||||
|
case path == nil:
|
||||||
|
return &SimpleValueEvaluationResult{
|
||||||
|
PathSegments: nil,
|
||||||
|
Value: v,
|
||||||
|
}, nil
|
||||||
|
case path.AttrPath != nil:
|
||||||
|
segments, value, err := e.attributeResolver.resolveAttrPath(v, path.AttrPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &SimpleValueEvaluationResult{
|
||||||
|
PathSegments: segments,
|
||||||
|
Value: value,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return e.filterByValuePath(v, path.ValuePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Evaluator) filterByValuePath(v reflect.Value, valuePath *ValuePathWithSubAttr) (*FilteredValuesEvaluationResult, error) {
|
||||||
|
segments, value, err := e.attributeResolver.resolveAttrPath(v, &valuePath.ValuePath.AttrPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// filtering does only work on slices
|
||||||
|
if value.Kind() != reflect.Slice {
|
||||||
|
return nil, zerrors.ThrowInvalidArgument(nil, "SCIM-21x2", "value path target must be a slice")
|
||||||
|
}
|
||||||
|
|
||||||
|
// apply the filter
|
||||||
|
var matches []*FilteredValuesEvaluationResultMatch
|
||||||
|
for i := 0; i < value.Len(); i++ {
|
||||||
|
element := value.Index(i)
|
||||||
|
if match, err := e.evaluateOr(element, &valuePath.ValuePath.ValFilter); err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if match && valuePath.SubAttr != nil {
|
||||||
|
subElement, err := e.attributeResolver.resolveField(element, *valuePath.SubAttr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
matches = append(matches, &FilteredValuesEvaluationResultMatch{
|
||||||
|
Value: subElement,
|
||||||
|
Element: element,
|
||||||
|
SourceIndex: i,
|
||||||
|
})
|
||||||
|
} else if match {
|
||||||
|
matches = append(matches, &FilteredValuesEvaluationResultMatch{
|
||||||
|
Value: element,
|
||||||
|
Element: element,
|
||||||
|
SourceIndex: i,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if valuePath.SubAttr != nil {
|
||||||
|
segments = append(segments, *valuePath.SubAttr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &FilteredValuesEvaluationResult{
|
||||||
|
PathSegments: segments,
|
||||||
|
Source: value,
|
||||||
|
Matches: matches,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Evaluator) evaluateOr(item reflect.Value, or *OrLogExp) (bool, error) {
|
||||||
|
if match, err := e.evaluateAnd(item, &or.Left); err != nil {
|
||||||
|
return false, err
|
||||||
|
} else if match {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if or.Right != nil {
|
||||||
|
return e.evaluateOr(item, or.Right)
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Evaluator) evaluateAnd(item reflect.Value, and *AndLogExp) (bool, error) {
|
||||||
|
if match, err := e.evaluateValueAtom(item, &and.Left); err != nil {
|
||||||
|
return false, err
|
||||||
|
} else if !match {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if and.Right != nil {
|
||||||
|
return e.evaluateAnd(item, and.Right)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Evaluator) evaluateValueAtom(item reflect.Value, atom *ValueAtom) (bool, error) {
|
||||||
|
switch {
|
||||||
|
case atom.SubFilter != nil:
|
||||||
|
return e.evaluateOr(item, &atom.SubFilter.OrExp)
|
||||||
|
case atom.Negation != nil:
|
||||||
|
if match, err := e.evaluateOr(item, &atom.Negation.OrExp); err != nil {
|
||||||
|
return false, err
|
||||||
|
} else {
|
||||||
|
return !match, nil
|
||||||
|
}
|
||||||
|
case atom.AttrExp != nil:
|
||||||
|
return e.evaluateAttrExp(item, atom.AttrExp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// atom.ValuePath is not evaluated since nested valuePaths are not supported
|
||||||
|
logging.Warn("Encountered unsupported nested value path")
|
||||||
|
return false, zerrors.ThrowInvalidArgument(nil, "SCIM-21x7", "nested value paths are not supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Evaluator) evaluateAttrExp(item reflect.Value, attrExp *AttrExp) (bool, error) {
|
||||||
|
if attrExp.UnaryCondition != nil {
|
||||||
|
return e.evaluateUnaryCondition(item, attrExp.UnaryCondition)
|
||||||
|
}
|
||||||
|
|
||||||
|
return e.evaluateBinaryCondition(item, attrExp.BinaryCondition)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Evaluator) evaluateUnaryCondition(item reflect.Value, condition *UnaryCondition) (bool, error) {
|
||||||
|
_, field, err := e.attributeResolver.resolveAttrPath(item, &condition.Left)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if field.Kind() == reflect.Ptr {
|
||||||
|
return !field.IsZero(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return !field.IsZero(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Evaluator) evaluateBinaryCondition(item reflect.Value, condition *BinaryCondition) (bool, error) {
|
||||||
|
_, field, err := e.attributeResolver.resolveAttrPath(item, &condition.Left)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return e.compareValues(field, &condition.Operator, &condition.Right)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Evaluator) compareValues(value reflect.Value, op *CompareOp, compValue *CompValue) (bool, error) {
|
||||||
|
if (op.Equal) && compValue.Null {
|
||||||
|
return value.IsNil() || value.IsZero(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if op.NotEqual && compValue.Null {
|
||||||
|
return !value.IsNil() && !value.IsZero(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:exhaustive
|
||||||
|
switch value.Kind() {
|
||||||
|
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||||
|
return e.compareUIntValue(value, op, compValue)
|
||||||
|
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||||
|
return e.compareIntValue(value, op, compValue)
|
||||||
|
case reflect.Float64, reflect.Float32:
|
||||||
|
return e.compareFloatValue(value, op, compValue)
|
||||||
|
case reflect.String:
|
||||||
|
return e.compareString(value, op, compValue)
|
||||||
|
case reflect.Bool:
|
||||||
|
return e.compareBool(value, op, compValue)
|
||||||
|
|
||||||
|
// for complex attributes, compare the "value" by default as required by the rfc
|
||||||
|
case reflect.Struct:
|
||||||
|
if defaultValue, err := e.attributeResolver.resolveField(value, "value"); err == nil {
|
||||||
|
return e.compareValues(defaultValue, op, compValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logging.WithFields("kind", value.Kind()).Warn("Encountered unsupported nested value path")
|
||||||
|
return false, zerrors.ThrowInvalidArgument(nil, "SCIM-23x8", "unsupported filter path value type")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Evaluator) compareUIntValue(value reflect.Value, op *CompareOp, compValue *CompValue) (bool, error) {
|
||||||
|
if compValue.Int == nil {
|
||||||
|
return false, zerrors.ThrowInvalidArgument(nil, "SCIM-23x8", "Invalid comparison type, expected integer")
|
||||||
|
}
|
||||||
|
|
||||||
|
intCompValue := uint64(*compValue.Int)
|
||||||
|
v := value.Uint()
|
||||||
|
switch {
|
||||||
|
case op.Equal:
|
||||||
|
return v == intCompValue, nil
|
||||||
|
case op.NotEqual:
|
||||||
|
return v != intCompValue, nil
|
||||||
|
case op.GreaterThan:
|
||||||
|
return v > intCompValue, nil
|
||||||
|
case op.GreaterThanOrEqual:
|
||||||
|
return v >= intCompValue, nil
|
||||||
|
case op.LessThan:
|
||||||
|
return v < intCompValue, nil
|
||||||
|
case op.LessThanOrEqual:
|
||||||
|
return v <= intCompValue, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, zerrors.ThrowInvalidArgumentf(nil, "SCIM-24x8", "Invalid comparison operator %s, not supported for number comparisons", op.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Evaluator) compareIntValue(value reflect.Value, op *CompareOp, compValue *CompValue) (bool, error) {
|
||||||
|
if compValue.Int == nil {
|
||||||
|
return false, zerrors.ThrowInvalidArgument(nil, "SCIM-23x9", "Invalid comparison type, expected integer")
|
||||||
|
}
|
||||||
|
|
||||||
|
intCompValue := int64(*compValue.Int)
|
||||||
|
v := value.Int()
|
||||||
|
switch {
|
||||||
|
case op.Equal:
|
||||||
|
return v == intCompValue, nil
|
||||||
|
case op.NotEqual:
|
||||||
|
return v != intCompValue, nil
|
||||||
|
case op.GreaterThan:
|
||||||
|
return v > intCompValue, nil
|
||||||
|
case op.GreaterThanOrEqual:
|
||||||
|
return v >= intCompValue, nil
|
||||||
|
case op.LessThan:
|
||||||
|
return v < intCompValue, nil
|
||||||
|
case op.LessThanOrEqual:
|
||||||
|
return v <= intCompValue, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, zerrors.ThrowInvalidArgumentf(nil, "SCIM-24x7", "Invalid comparison operator %s, not supported for number comparisons", op.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Evaluator) compareFloatValue(value reflect.Value, op *CompareOp, compValue *CompValue) (bool, error) {
|
||||||
|
var floatCompValue float64
|
||||||
|
switch {
|
||||||
|
case compValue.Float != nil:
|
||||||
|
floatCompValue = *compValue.Float
|
||||||
|
case compValue.Int != nil:
|
||||||
|
floatCompValue = float64(*compValue.Int)
|
||||||
|
default:
|
||||||
|
return false, zerrors.ThrowInvalidArgument(nil, "SCIM-24x1", "Invalid comparison type, expected number")
|
||||||
|
}
|
||||||
|
|
||||||
|
v := value.Float()
|
||||||
|
switch {
|
||||||
|
case op.Equal:
|
||||||
|
return v == floatCompValue, nil
|
||||||
|
case op.NotEqual:
|
||||||
|
return v != floatCompValue, nil
|
||||||
|
case op.GreaterThan:
|
||||||
|
return v > floatCompValue, nil
|
||||||
|
case op.GreaterThanOrEqual:
|
||||||
|
return v >= floatCompValue, nil
|
||||||
|
case op.LessThan:
|
||||||
|
return v < floatCompValue, nil
|
||||||
|
case op.LessThanOrEqual:
|
||||||
|
return v <= floatCompValue, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, zerrors.ThrowInvalidArgumentf(nil, "SCIM-24x6", "Invalid comparison operator %s, not supported for number comparisons", op.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Evaluator) compareString(value reflect.Value, op *CompareOp, compValue *CompValue) (bool, error) {
|
||||||
|
if compValue.StringValue == nil {
|
||||||
|
return false, zerrors.ThrowInvalidArgument(nil, "SCIM-24x2", "Invalid comparison type, expected string")
|
||||||
|
}
|
||||||
|
|
||||||
|
strCompValue := *compValue.StringValue
|
||||||
|
v := value.String()
|
||||||
|
switch {
|
||||||
|
case op.Equal:
|
||||||
|
return v == strCompValue, nil
|
||||||
|
case op.NotEqual:
|
||||||
|
return v != strCompValue, nil
|
||||||
|
case op.Contains:
|
||||||
|
return strings.Contains(v, strCompValue), nil
|
||||||
|
case op.StartsWith:
|
||||||
|
return strings.HasPrefix(v, strCompValue), nil
|
||||||
|
case op.EndsWith:
|
||||||
|
return strings.HasSuffix(v, strCompValue), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, zerrors.ThrowInvalidArgumentf(nil, "SCIM-24x5", "Invalid comparison operator %s, not supported for string comparisons", op.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Evaluator) compareBool(value reflect.Value, op *CompareOp, compValue *CompValue) (bool, error) {
|
||||||
|
if !compValue.BooleanTrue && !compValue.BooleanFalse {
|
||||||
|
return false, zerrors.ThrowInvalidArgument(nil, "SCIM-24x3", "Invalid comparison type, expected bool")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !op.Equal && !op.NotEqual {
|
||||||
|
return false, zerrors.ThrowInvalidArgumentf(nil, "SCIM-24x4", "Invalid comparison operator %s, expected eq or ne", op.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.Bool() == (op.Equal && compValue.BooleanTrue), nil
|
||||||
|
}
|
107
internal/api/scim/resources/filter/path_parser.go
Normal file
107
internal/api/scim/resources/filter/path_parser.go
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
package filter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/zitadel/logging"
|
||||||
|
|
||||||
|
"github.com/zitadel/zitadel/internal/api/scim/schemas"
|
||||||
|
"github.com/zitadel/zitadel/internal/api/scim/serrors"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Path The root ast node for the path grammar
|
||||||
|
// PATH = attrPath / valuePath [subAttr]
|
||||||
|
type Path struct {
|
||||||
|
ValuePath *ValuePathWithSubAttr `parser:"@@ |"`
|
||||||
|
AttrPath *AttrPath `parser:"@@"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ValuePathWithSubAttr struct {
|
||||||
|
ValuePath ValuePath `parser:"@@"`
|
||||||
|
SubAttr *string `parser:"('.' @AttrName)?"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var scimPathParser = buildParser[Path]()
|
||||||
|
|
||||||
|
func ParsePath(path string) (*Path, error) {
|
||||||
|
if path == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(path) > maxInputLength {
|
||||||
|
logging.WithFields("len", len(path)).Infof("scim: path exceeds maximum allowed length: %d", maxInputLength)
|
||||||
|
return nil, serrors.ThrowInvalidFilter(fmt.Errorf("path exceeds maximum allowed length: %d", maxInputLength))
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedPath, err := scimPathParser.ParseString("", path)
|
||||||
|
if err != nil {
|
||||||
|
logging.WithError(err).Info("scim: failed to parse path")
|
||||||
|
return nil, serrors.ThrowInvalidFilter(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Path) UnmarshalJSON(data []byte) error {
|
||||||
|
var rawPath string
|
||||||
|
if err := json.Unmarshal(data, &rawPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if rawPath == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedPath, err := ParsePath(rawPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
*p = *parsedPath
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Path) String() string {
|
||||||
|
if p.ValuePath != nil {
|
||||||
|
return p.ValuePath.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.AttrPath.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Path) IsZero() bool {
|
||||||
|
return p == nil || *p == Path{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Path) Segments(schema schemas.ScimSchemaType) ([]string, error) {
|
||||||
|
if p.ValuePath != nil {
|
||||||
|
return p.ValuePath.Segments(schema)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := p.AttrPath.validateSchema(schema); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return p.AttrPath.Segments(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *ValuePathWithSubAttr) String() string {
|
||||||
|
if v.SubAttr != nil {
|
||||||
|
return v.ValuePath.String() + "." + *v.SubAttr
|
||||||
|
}
|
||||||
|
|
||||||
|
return v.ValuePath.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *ValuePathWithSubAttr) Segments(schema schemas.ScimSchemaType) ([]string, error) {
|
||||||
|
if err := v.ValuePath.AttrPath.validateSchema(schema); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
segments := v.ValuePath.AttrPath.Segments()
|
||||||
|
if v.SubAttr != nil {
|
||||||
|
segments = append(segments, *v.SubAttr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments, nil
|
||||||
|
}
|
160
internal/api/scim/resources/filter/path_parser_test.go
Normal file
160
internal/api/scim/resources/filter/path_parser_test.go
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
package filter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/muhlemmer/gu"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParsePath(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
want *Path
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty",
|
||||||
|
path: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "too long",
|
||||||
|
path: longString,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid syntax",
|
||||||
|
path: "fooBar[['baz']]",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// test cases from https://datatracker.ietf.org/doc/html/rfc7644#section-3.5.2
|
||||||
|
{
|
||||||
|
name: "simple",
|
||||||
|
path: "members",
|
||||||
|
want: &Path{
|
||||||
|
AttrPath: &AttrPath{
|
||||||
|
AttrName: "members",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nested",
|
||||||
|
path: "name.familyName",
|
||||||
|
want: &Path{
|
||||||
|
AttrPath: &AttrPath{
|
||||||
|
AttrName: "name",
|
||||||
|
SubAttr: gu.Ptr("familyName"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with filter",
|
||||||
|
path: `addresses[type eq "work"]`,
|
||||||
|
want: &Path{
|
||||||
|
ValuePath: &ValuePathWithSubAttr{
|
||||||
|
ValuePath: ValuePath{
|
||||||
|
AttrPath: AttrPath{
|
||||||
|
AttrName: "addresses",
|
||||||
|
},
|
||||||
|
ValFilter: OrLogExp{
|
||||||
|
Left: AndLogExp{
|
||||||
|
Left: ValueAtom{
|
||||||
|
AttrExp: &AttrExp{
|
||||||
|
BinaryCondition: &BinaryCondition{
|
||||||
|
Left: AttrPath{
|
||||||
|
AttrName: "type",
|
||||||
|
},
|
||||||
|
Operator: CompareOp{
|
||||||
|
Equal: true,
|
||||||
|
},
|
||||||
|
Right: CompValue{
|
||||||
|
StringValue: gu.Ptr("work"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with filter and submember",
|
||||||
|
path: `members[value pr].displayName`,
|
||||||
|
want: &Path{
|
||||||
|
ValuePath: &ValuePathWithSubAttr{
|
||||||
|
ValuePath: ValuePath{
|
||||||
|
AttrPath: AttrPath{
|
||||||
|
AttrName: "members",
|
||||||
|
},
|
||||||
|
ValFilter: OrLogExp{
|
||||||
|
Left: AndLogExp{
|
||||||
|
Left: ValueAtom{
|
||||||
|
AttrExp: &AttrExp{
|
||||||
|
UnaryCondition: &UnaryCondition{
|
||||||
|
Left: AttrPath{
|
||||||
|
AttrName: "value",
|
||||||
|
},
|
||||||
|
Operator: UnaryConditionOperator{
|
||||||
|
Present: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
SubAttr: gu.Ptr("displayName"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with binary filter",
|
||||||
|
path: `entitlements[primary eq true]`,
|
||||||
|
want: &Path{
|
||||||
|
ValuePath: &ValuePathWithSubAttr{
|
||||||
|
ValuePath: ValuePath{
|
||||||
|
AttrPath: AttrPath{
|
||||||
|
AttrName: "entitlements",
|
||||||
|
},
|
||||||
|
ValFilter: OrLogExp{
|
||||||
|
Left: AndLogExp{
|
||||||
|
Left: ValueAtom{
|
||||||
|
AttrExp: &AttrExp{
|
||||||
|
BinaryCondition: &BinaryCondition{
|
||||||
|
Left: AttrPath{
|
||||||
|
AttrName: "primary",
|
||||||
|
},
|
||||||
|
Operator: CompareOp{
|
||||||
|
Equal: true,
|
||||||
|
},
|
||||||
|
Right: CompValue{
|
||||||
|
BooleanTrue: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := ParsePath(tt.path)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("ParsePath() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !reflect.DeepEqual(got, tt.want) {
|
||||||
|
t.Errorf("ParsePath() got = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
273
internal/api/scim/resources/patch/patch.go
Normal file
273
internal/api/scim/resources/patch/patch.go
Normal file
@ -0,0 +1,273 @@
|
|||||||
|
package patch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"reflect"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/zitadel/logging"
|
||||||
|
|
||||||
|
"github.com/zitadel/zitadel/internal/api/scim/resources/filter"
|
||||||
|
"github.com/zitadel/zitadel/internal/api/scim/schemas"
|
||||||
|
"github.com/zitadel/zitadel/internal/api/scim/serrors"
|
||||||
|
"github.com/zitadel/zitadel/internal/zerrors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type OperationRequest struct {
|
||||||
|
Schemas []schemas.ScimSchemaType `json:"Schemas"`
|
||||||
|
Operations []*Operation `json:"Operations"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Operation struct {
|
||||||
|
Operation OperationType `json:"op"`
|
||||||
|
Path *filter.Path `json:"path"`
|
||||||
|
Value json.RawMessage `json:"value"`
|
||||||
|
valueIsArray bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type OperationCollection []*Operation
|
||||||
|
|
||||||
|
type OperationType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
OperationTypeAdd OperationType = "add"
|
||||||
|
OperationTypeRemove OperationType = "remove"
|
||||||
|
OperationTypeReplace OperationType = "replace"
|
||||||
|
|
||||||
|
fieldNamePrimary = "Primary"
|
||||||
|
fieldNameValue = "Value"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ResourcePatcher interface {
|
||||||
|
FilterEvaluator() *filter.Evaluator
|
||||||
|
Added(attributePath []string) error
|
||||||
|
Replaced(attributePath []string) error
|
||||||
|
Removed(attributePath []string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (req *OperationRequest) Validate() error {
|
||||||
|
if !slices.Contains(req.Schemas, schemas.IdPatchOperation) {
|
||||||
|
return serrors.ThrowInvalidSyntax(zerrors.ThrowInvalidArgumentf(nil, "SCIM-xy1schema", "Expected schema %v is not provided", schemas.IdPatchOperation))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, op := range req.Operations {
|
||||||
|
if err := op.validate(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (op *Operation) validate() error {
|
||||||
|
if !op.Operation.isValid() {
|
||||||
|
return serrors.ThrowInvalidValue(zerrors.ThrowInvalidArgumentf(nil, "SCIM-opty1", "Patch op %s not supported", op.Operation))
|
||||||
|
}
|
||||||
|
|
||||||
|
// json deserialization initializes this field if an empty string is provided
|
||||||
|
// to not special case this in the further code,
|
||||||
|
// set it to nil here.
|
||||||
|
if op.Path.IsZero() {
|
||||||
|
op.Path = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
op.valueIsArray = strings.HasPrefix(strings.TrimPrefix(string(op.Value), " "), "[")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ops OperationCollection) Apply(patcher ResourcePatcher, value interface{}) error {
|
||||||
|
for _, op := range ops {
|
||||||
|
if err := op.validate(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := op.apply(patcher, value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (op *Operation) apply(patcher ResourcePatcher, value interface{}) error {
|
||||||
|
switch op.Operation {
|
||||||
|
case OperationTypeRemove:
|
||||||
|
return applyRemovePatch(patcher, op, value)
|
||||||
|
case OperationTypeReplace:
|
||||||
|
return applyReplacePatch(patcher, op, value)
|
||||||
|
case OperationTypeAdd:
|
||||||
|
return applyAddPatch(patcher, op, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return zerrors.ThrowInvalidArgumentf(nil, "SCIM-opty3", "SCIM patch: Invalid operation %v", op.Operation)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o OperationType) isValid() bool {
|
||||||
|
switch o {
|
||||||
|
case OperationTypeAdd, OperationTypeRemove, OperationTypeReplace:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func flattenAndApplyPatchOperations(patcher ResourcePatcher, op *Operation, value interface{}) error {
|
||||||
|
ops, err := flattenPatchOperations(op)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, flattenedOperation := range ops {
|
||||||
|
if err = flattenedOperation.apply(patcher, value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// flattenPatchOperations flattens patch operations without a path
|
||||||
|
// it converts an op { "operation": "add", "value": { "path1": "value1", "path2": "value2" } }
|
||||||
|
// into [ { "operation": "add", "path": "path1", "value": "value1" }, { "operation": "add", "path": "path2", "value": "value2" } ]
|
||||||
|
func flattenPatchOperations(op *Operation) ([]*Operation, error) {
|
||||||
|
if op.Path != nil {
|
||||||
|
panic("Only operations without a path can be flattened")
|
||||||
|
}
|
||||||
|
|
||||||
|
patches := map[string]json.RawMessage{}
|
||||||
|
if err := json.Unmarshal(op.Value, &patches); err != nil {
|
||||||
|
logging.WithError(err).Error("SCIM: Invalid patch value while flattening")
|
||||||
|
return nil, zerrors.ThrowInvalidArgument(err, "SCIM-ioyl1", "Invalid patch value")
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]*Operation, 0, len(patches))
|
||||||
|
for path, value := range patches {
|
||||||
|
result = append(result, &Operation{
|
||||||
|
Operation: op.Operation,
|
||||||
|
Path: &filter.Path{
|
||||||
|
AttrPath: &filter.AttrPath{
|
||||||
|
AttrName: path,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Value: value,
|
||||||
|
valueIsArray: strings.HasPrefix(string(value), "["),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// unmarshalPatchValuesSlice unmarshal the raw json value (a scalar value, object or array) into a new slice
|
||||||
|
func unmarshalPatchValuesSlice(elementTypePtr reflect.Type, value json.RawMessage, valueIsArray bool) (reflect.Value, error) {
|
||||||
|
if elementTypePtr.Kind() != reflect.Ptr {
|
||||||
|
logging.Panicf("elementType must be a pointer to a struct, but is %s", elementTypePtr.Name())
|
||||||
|
return reflect.Value{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !valueIsArray {
|
||||||
|
newElement := reflect.New(elementTypePtr.Elem())
|
||||||
|
if err := unmarshalPatchValue(value, newElement); err != nil {
|
||||||
|
return reflect.Value{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
newSlice := reflect.MakeSlice(reflect.SliceOf(elementTypePtr), 1, 1)
|
||||||
|
newSlice.Index(0).Set(newElement)
|
||||||
|
return newSlice, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
newSlicePtr := reflect.New(reflect.SliceOf(elementTypePtr))
|
||||||
|
newSlice := newSlicePtr.Elem()
|
||||||
|
if err := json.Unmarshal(value, newSlicePtr.Interface()); err != nil {
|
||||||
|
logging.WithError(err).Error("SCIM: Invalid patch values")
|
||||||
|
return reflect.Value{}, zerrors.ThrowInvalidArgument(err, "SCIM-opxx8", "Invalid patch values")
|
||||||
|
}
|
||||||
|
return newSlice, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func unmarshalPatchValue(newValue json.RawMessage, targetElement reflect.Value) error {
|
||||||
|
if targetElement.Kind() != reflect.Ptr {
|
||||||
|
targetElement = targetElement.Addr()
|
||||||
|
}
|
||||||
|
|
||||||
|
if targetElement.IsNil() {
|
||||||
|
targetElement.Set(reflect.New(targetElement.Type().Elem()))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(newValue, targetElement.Interface()); err != nil {
|
||||||
|
logging.WithError(err).Error("SCIM: Invalid patch value")
|
||||||
|
return zerrors.ThrowInvalidArgument(err, "SCIM-opty9", "Invalid patch value")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureSinglePrimary ensures the modification on a slice results in max one primary element.
|
||||||
|
// modifiedSlice contains the patched slice.
|
||||||
|
// modifiedElementsSlice contains only the modified elements.
|
||||||
|
// if a new element has Primary set to true and an existing is also Primary, the existing Primary flag is set to false.
|
||||||
|
// returns an error if multiple modifiedElements have a primary value of true.
|
||||||
|
func ensureSinglePrimary(modifiedSlice reflect.Value, modifiedElementsSlice []reflect.Value, modifiedElementIndexes map[int]bool) error {
|
||||||
|
if len(modifiedElementsSlice) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
hasPrimary, err := isAnyPrimary(modifiedElementsSlice)
|
||||||
|
if err != nil || !hasPrimary {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < modifiedSlice.Len(); i++ {
|
||||||
|
if mod, ok := modifiedElementIndexes[i]; ok && mod {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
sliceElement := modifiedSlice.Index(i)
|
||||||
|
if sliceElement.Kind() == reflect.Ptr {
|
||||||
|
sliceElement = sliceElement.Elem()
|
||||||
|
}
|
||||||
|
|
||||||
|
sliceElementPrimaryField := sliceElement.FieldByName(fieldNamePrimary)
|
||||||
|
if !sliceElementPrimaryField.IsValid() || !sliceElementPrimaryField.Bool() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
sliceElementPrimaryField.SetBool(false)
|
||||||
|
|
||||||
|
// we can stop at the first primary,
|
||||||
|
// since there can only be one primary in a slice.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAnyPrimary(elements []reflect.Value) (bool, error) {
|
||||||
|
foundPrimary := false
|
||||||
|
for _, element := range elements {
|
||||||
|
if !isPrimary(element) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if foundPrimary {
|
||||||
|
return true, zerrors.ThrowInvalidArgument(nil, "SCIM-1d23", "Cannot add multiple primary values in one patch operation")
|
||||||
|
}
|
||||||
|
|
||||||
|
foundPrimary = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return foundPrimary, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPrimary(element reflect.Value) bool {
|
||||||
|
if element.Kind() == reflect.Ptr {
|
||||||
|
element = element.Elem()
|
||||||
|
}
|
||||||
|
|
||||||
|
if element.Kind() != reflect.Struct {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
primaryField := element.FieldByName(fieldNamePrimary)
|
||||||
|
return primaryField.IsValid() && primaryField.Bool()
|
||||||
|
}
|
112
internal/api/scim/resources/patch/patch_add.go
Normal file
112
internal/api/scim/resources/patch/patch_add.go
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
package patch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
|
||||||
|
"github.com/zitadel/zitadel/internal/api/scim/resources/filter"
|
||||||
|
"github.com/zitadel/zitadel/internal/api/scim/serrors"
|
||||||
|
"github.com/zitadel/zitadel/internal/zerrors"
|
||||||
|
)
|
||||||
|
|
||||||
|
func applyAddPatch(patcher ResourcePatcher, op *Operation, value interface{}) error {
|
||||||
|
if op.Path == nil {
|
||||||
|
return flattenAndApplyPatchOperations(patcher, op, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := patcher.FilterEvaluator().Evaluate(reflect.ValueOf(value), op.Path)
|
||||||
|
if err != nil {
|
||||||
|
return serrors.ThrowInvalidPath(zerrors.ThrowInvalidArgument(err, "SCIM-opzz8", "Failed to evaluate path"))
|
||||||
|
}
|
||||||
|
|
||||||
|
evaluationResult, ok := result.(*filter.SimpleValueEvaluationResult)
|
||||||
|
if !ok {
|
||||||
|
return serrors.ThrowInvalidPath(zerrors.ThrowInvalidArgument(nil, "SCIM-opty8", "Filtered paths are not allowed for add patch operations"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if evaluationResult.Value.Kind() != reflect.Slice {
|
||||||
|
return applyReplacePatchSimple(patcher, evaluationResult, op.Value, op.valueIsArray)
|
||||||
|
}
|
||||||
|
|
||||||
|
elementType := evaluationResult.Value.Type().Elem()
|
||||||
|
newElementsSlice, err := unmarshalPatchValuesSlice(elementType, op.Value, op.valueIsArray)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
oldLen := evaluationResult.Value.Len()
|
||||||
|
newSlice := reflect.MakeSlice(reflect.SliceOf(elementType), oldLen, oldLen+newElementsSlice.Len())
|
||||||
|
|
||||||
|
// copy over existing values
|
||||||
|
reflect.Copy(newSlice, evaluationResult.Value)
|
||||||
|
|
||||||
|
// according to the RFC only "new" values should be added
|
||||||
|
// existing values should be replaced
|
||||||
|
newSlice, modifiedIndexes := addOrReplaceByValue(newSlice, newElementsSlice)
|
||||||
|
|
||||||
|
evaluationResult.Value.Set(newSlice)
|
||||||
|
if err = ensureSinglePrimaryAdded(evaluationResult.Value, newElementsSlice, modifiedIndexes); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return patcher.Added(evaluationResult.PathSegments)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureSinglePrimaryAdded(resultSlice, newSlice reflect.Value, modifiedIndexes map[int]bool) error {
|
||||||
|
modifiedValues := make([]reflect.Value, newSlice.Len())
|
||||||
|
for i := 0; i < newSlice.Len(); i++ {
|
||||||
|
modifiedValues[i] = newSlice.Index(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ensureSinglePrimary(resultSlice, modifiedValues, modifiedIndexes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func addOrReplaceByValue(entries, newEntries reflect.Value) (reflect.Value, map[int]bool) {
|
||||||
|
modifiedIndexes := make(map[int]bool, newEntries.Len())
|
||||||
|
if entries.Len() == 0 {
|
||||||
|
for i := 0; i < newEntries.Len(); i++ {
|
||||||
|
modifiedIndexes[i] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return newEntries, modifiedIndexes
|
||||||
|
}
|
||||||
|
|
||||||
|
valueField := entries.Index(0).Elem().FieldByName(fieldNameValue)
|
||||||
|
if !valueField.IsValid() || valueField.Kind() != reflect.String {
|
||||||
|
entriesLen := entries.Len()
|
||||||
|
for i := 0; i < newEntries.Len(); i++ {
|
||||||
|
modifiedIndexes[i+entriesLen] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return reflect.AppendSlice(entries, newEntries), modifiedIndexes
|
||||||
|
}
|
||||||
|
|
||||||
|
existingValueIndexes := make(map[string]int, entries.Len())
|
||||||
|
for i := 0; i < entries.Len(); i++ {
|
||||||
|
value := entries.Index(i).Elem().FieldByName(fieldNameValue).String()
|
||||||
|
if _, ok := existingValueIndexes[value]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
existingValueIndexes[value] = i
|
||||||
|
}
|
||||||
|
|
||||||
|
entriesLen := entries.Len()
|
||||||
|
for i := 0; i < newEntries.Len(); i++ {
|
||||||
|
newEntry := newEntries.Index(i)
|
||||||
|
value := newEntry.Elem().FieldByName(fieldNameValue).String()
|
||||||
|
index, valueExists := existingValueIndexes[value]
|
||||||
|
|
||||||
|
// according to the rfc if the value already exists it should be replaced
|
||||||
|
if valueExists {
|
||||||
|
entries.Index(index).Set(newEntry)
|
||||||
|
modifiedIndexes[index] = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
entries = reflect.Append(entries, newEntry)
|
||||||
|
modifiedIndexes[entriesLen] = true
|
||||||
|
entriesLen++
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries, modifiedIndexes
|
||||||
|
}
|
73
internal/api/scim/resources/patch/patch_remove.go
Normal file
73
internal/api/scim/resources/patch/patch_remove.go
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
package patch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
|
||||||
|
"github.com/zitadel/logging"
|
||||||
|
|
||||||
|
"github.com/zitadel/zitadel/internal/api/scim/resources/filter"
|
||||||
|
"github.com/zitadel/zitadel/internal/api/scim/serrors"
|
||||||
|
"github.com/zitadel/zitadel/internal/zerrors"
|
||||||
|
)
|
||||||
|
|
||||||
|
func applyRemovePatch(patcher ResourcePatcher, op *Operation, value interface{}) error {
|
||||||
|
// the root cannot be removed
|
||||||
|
if op.Path == nil {
|
||||||
|
logging.Info("SCIM: remove patch without path")
|
||||||
|
return serrors.ThrowNoTarget(zerrors.ThrowInvalidArgument(nil, "SCIM-ozzy54", "Remove patch without path is not supported"))
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := patcher.FilterEvaluator().Evaluate(reflect.ValueOf(value), op.Path)
|
||||||
|
if err != nil {
|
||||||
|
return serrors.ThrowInvalidPath(zerrors.ThrowInvalidArgument(err, "SCIM-sd41", "Failed to evaluate path"))
|
||||||
|
}
|
||||||
|
|
||||||
|
switch filterResult := result.(type) {
|
||||||
|
case *filter.SimpleValueEvaluationResult:
|
||||||
|
return applyRemovePatchSimple(patcher, filterResult)
|
||||||
|
case *filter.FilteredValuesEvaluationResult:
|
||||||
|
return applyRemovePatchFiltered(patcher, filterResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
logging.Errorf("SCIM remove patch: unsupported filter type %T", result)
|
||||||
|
return serrors.ThrowInvalidPath(zerrors.ThrowInvalidArgument(err, "SCIM-12syw", "Invalid patch path"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyRemovePatchSimple(patcher ResourcePatcher, filterResult *filter.SimpleValueEvaluationResult) error {
|
||||||
|
filterResult.Value.Set(reflect.Zero(filterResult.Value.Type()))
|
||||||
|
return patcher.Removed(filterResult.PathSegments)
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyRemovePatchFiltered(patcher ResourcePatcher, filterResult *filter.FilteredValuesEvaluationResult) error {
|
||||||
|
if len(filterResult.Matches) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// if a subattribute is selected, set that one to nil instead of removing the elements from the slice
|
||||||
|
if len(filterResult.PathSegments) > 1 {
|
||||||
|
for _, match := range filterResult.Matches {
|
||||||
|
match.Value.Set(reflect.Zero(match.Value.Type()))
|
||||||
|
}
|
||||||
|
|
||||||
|
return patcher.Removed(filterResult.PathSegments)
|
||||||
|
}
|
||||||
|
|
||||||
|
slice := filterResult.Source
|
||||||
|
sliceLen := slice.Len()
|
||||||
|
|
||||||
|
// if all elements are matched, set the field to nil
|
||||||
|
if sliceLen == len(filterResult.Matches) {
|
||||||
|
filterResult.Source.Set(reflect.Zero(slice.Type()))
|
||||||
|
return patcher.Removed(filterResult.PathSegments)
|
||||||
|
}
|
||||||
|
|
||||||
|
// start at the very last matched value to keep correct indexing
|
||||||
|
for i := len(filterResult.Matches) - 1; i >= 0; i-- {
|
||||||
|
match := filterResult.Matches[i]
|
||||||
|
slice = reflect.AppendSlice(slice.Slice(0, match.SourceIndex), slice.Slice(match.SourceIndex+1, sliceLen))
|
||||||
|
sliceLen--
|
||||||
|
}
|
||||||
|
|
||||||
|
filterResult.Source.Set(slice)
|
||||||
|
return patcher.Removed(filterResult.PathSegments)
|
||||||
|
}
|
105
internal/api/scim/resources/patch/patch_replace.go
Normal file
105
internal/api/scim/resources/patch/patch_replace.go
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
package patch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"reflect"
|
||||||
|
|
||||||
|
"github.com/zitadel/logging"
|
||||||
|
|
||||||
|
"github.com/zitadel/zitadel/internal/api/scim/resources/filter"
|
||||||
|
"github.com/zitadel/zitadel/internal/api/scim/serrors"
|
||||||
|
"github.com/zitadel/zitadel/internal/zerrors"
|
||||||
|
)
|
||||||
|
|
||||||
|
func applyReplacePatch(patcher ResourcePatcher, op *Operation, value interface{}) error {
|
||||||
|
if op.Path == nil {
|
||||||
|
return flattenAndApplyPatchOperations(patcher, op, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := patcher.FilterEvaluator().Evaluate(reflect.ValueOf(value), op.Path)
|
||||||
|
if err != nil {
|
||||||
|
return serrors.ThrowInvalidPath(zerrors.ThrowInvalidArgument(err, "SCIM-i2o3", "Failed to evaluate path"))
|
||||||
|
}
|
||||||
|
|
||||||
|
switch filterResult := result.(type) {
|
||||||
|
case *filter.SimpleValueEvaluationResult:
|
||||||
|
return applyReplacePatchSimple(patcher, filterResult, op.Value, op.valueIsArray)
|
||||||
|
case *filter.FilteredValuesEvaluationResult:
|
||||||
|
return applyReplacePatchFiltered(patcher, filterResult, op.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
logging.Errorf("SCIM replace patch: unsupported filter type %T", result)
|
||||||
|
return serrors.ThrowInvalidPath(zerrors.ThrowInvalidArgument(err, "SCIM-optu9", "Invalid patch path"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyReplacePatchSimple(patcher ResourcePatcher, evaluationResult *filter.SimpleValueEvaluationResult, newValueRaw json.RawMessage, valueIsArray bool) error {
|
||||||
|
// patch value is an array
|
||||||
|
// or it is a scalar or an object but the target is a slice
|
||||||
|
// unmarshal it as a slice and set it on the target, clearing all existing entries
|
||||||
|
if valueIsArray || evaluationResult.Value.Kind() == reflect.Slice {
|
||||||
|
return applyReplacePatchSimpleSlice(patcher, evaluationResult, newValueRaw, valueIsArray)
|
||||||
|
}
|
||||||
|
|
||||||
|
// patch value and target is a scalar or object
|
||||||
|
if err := unmarshalPatchValue(newValueRaw, evaluationResult.Value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return patcher.Replaced(evaluationResult.PathSegments)
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyReplacePatchSimpleSlice(patcher ResourcePatcher, evaluationResult *filter.SimpleValueEvaluationResult, newValueRaw json.RawMessage, valueIsArray bool) error {
|
||||||
|
if evaluationResult.Value.Kind() != reflect.Slice {
|
||||||
|
return zerrors.ThrowInvalidArgument(nil, "SCIM-E345X", "Cannot apply array patch value to single value attribute")
|
||||||
|
}
|
||||||
|
|
||||||
|
values, err := unmarshalPatchValuesSlice(evaluationResult.Value.Type().Elem(), newValueRaw, valueIsArray)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
evaluationResult.Value.Set(values)
|
||||||
|
modifiedIndexes := make(map[int]bool, values.Len())
|
||||||
|
for i := 0; i < values.Len(); i++ {
|
||||||
|
modifiedIndexes[i] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = ensureSinglePrimaryAdded(values, values, modifiedIndexes); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return patcher.Replaced(evaluationResult.PathSegments)
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyReplacePatchFiltered(patcher ResourcePatcher, result *filter.FilteredValuesEvaluationResult, newValueRaw json.RawMessage) error {
|
||||||
|
if len(result.Matches) == 0 {
|
||||||
|
return serrors.ThrowNoTarget(zerrors.ThrowInvalidArgument(nil, "SCIM-4513", "Path evaluation resulted in no matches"))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, match := range result.Matches {
|
||||||
|
if err := unmarshalPatchValue(newValueRaw, match.Value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ensureSinglePrimaryBasedOnMatches(result.Source, result.Matches); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return patcher.Replaced(result.PathSegments)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureSinglePrimaryBasedOnMatches(slice reflect.Value, matches []*filter.FilteredValuesEvaluationResultMatch) error {
|
||||||
|
if len(matches) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
modifiedElements := make([]reflect.Value, 0, len(matches))
|
||||||
|
modifiedIndexes := make(map[int]bool, len(matches))
|
||||||
|
for _, match := range matches {
|
||||||
|
modifiedElements = append(modifiedElements, match.Element)
|
||||||
|
modifiedIndexes[match.SourceIndex] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return ensureSinglePrimary(slice, modifiedElements, modifiedIndexes)
|
||||||
|
}
|
@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"github.com/zitadel/zitadel/internal/api/authz"
|
"github.com/zitadel/zitadel/internal/api/authz"
|
||||||
"github.com/zitadel/zitadel/internal/api/http"
|
"github.com/zitadel/zitadel/internal/api/http"
|
||||||
|
"github.com/zitadel/zitadel/internal/api/scim/resources/patch"
|
||||||
"github.com/zitadel/zitadel/internal/api/scim/schemas"
|
"github.com/zitadel/zitadel/internal/api/scim/schemas"
|
||||||
"github.com/zitadel/zitadel/internal/domain"
|
"github.com/zitadel/zitadel/internal/domain"
|
||||||
)
|
)
|
||||||
@ -20,6 +21,7 @@ type ResourceHandler[T ResourceHolder] interface {
|
|||||||
|
|
||||||
Create(ctx context.Context, resource T) (T, error)
|
Create(ctx context.Context, resource T) (T, error)
|
||||||
Replace(ctx context.Context, id string, resource T) (T, error)
|
Replace(ctx context.Context, id string, resource T) (T, error)
|
||||||
|
Update(ctx context.Context, id string, operations patch.OperationCollection) error
|
||||||
Delete(ctx context.Context, id string) error
|
Delete(ctx context.Context, id string) error
|
||||||
Get(ctx context.Context, id string) (T, error)
|
Get(ctx context.Context, id string) (T, error)
|
||||||
List(ctx context.Context, request *ListRequest) (*ListResponse[T], error)
|
List(ctx context.Context, request *ListRequest) (*ListResponse[T], error)
|
||||||
|
@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
|
|
||||||
|
"github.com/zitadel/zitadel/internal/api/scim/resources/patch"
|
||||||
"github.com/zitadel/zitadel/internal/api/scim/serrors"
|
"github.com/zitadel/zitadel/internal/api/scim/serrors"
|
||||||
"github.com/zitadel/zitadel/internal/zerrors"
|
"github.com/zitadel/zitadel/internal/zerrors"
|
||||||
)
|
)
|
||||||
@ -40,6 +41,30 @@ func (adapter *ResourceHandlerAdapter[T]) Replace(r *http.Request) (T, error) {
|
|||||||
return adapter.handler.Replace(r.Context(), id, entity)
|
return adapter.handler.Replace(r.Context(), id, entity)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (adapter *ResourceHandlerAdapter[T]) Update(r *http.Request) error {
|
||||||
|
request := new(patch.OperationRequest)
|
||||||
|
err := json.NewDecoder(r.Body).Decode(request)
|
||||||
|
if err != nil {
|
||||||
|
if zerrors.IsZitadelError(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return serrors.ThrowInvalidSyntax(zerrors.ThrowInvalidArgumentf(nil, "SCIM-ucrjson2", "Could not deserialize json: %v", err.Error()))
|
||||||
|
}
|
||||||
|
|
||||||
|
err = request.Validate()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(request.Operations) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
id := mux.Vars(r)["id"]
|
||||||
|
return adapter.handler.Update(r.Context(), id, request.Operations)
|
||||||
|
}
|
||||||
|
|
||||||
func (adapter *ResourceHandlerAdapter[T]) Delete(r *http.Request) error {
|
func (adapter *ResourceHandlerAdapter[T]) Delete(r *http.Request) error {
|
||||||
id := mux.Vars(r)["id"]
|
id := mux.Vars(r)["id"]
|
||||||
return adapter.handler.Delete(r.Context(), id)
|
return adapter.handler.Delete(r.Context(), id)
|
||||||
|
@ -7,17 +7,22 @@ import (
|
|||||||
|
|
||||||
"github.com/zitadel/zitadel/internal/api/authz"
|
"github.com/zitadel/zitadel/internal/api/authz"
|
||||||
scim_config "github.com/zitadel/zitadel/internal/api/scim/config"
|
scim_config "github.com/zitadel/zitadel/internal/api/scim/config"
|
||||||
|
"github.com/zitadel/zitadel/internal/api/scim/resources/filter"
|
||||||
|
"github.com/zitadel/zitadel/internal/api/scim/resources/patch"
|
||||||
scim_schemas "github.com/zitadel/zitadel/internal/api/scim/schemas"
|
scim_schemas "github.com/zitadel/zitadel/internal/api/scim/schemas"
|
||||||
"github.com/zitadel/zitadel/internal/command"
|
"github.com/zitadel/zitadel/internal/command"
|
||||||
"github.com/zitadel/zitadel/internal/crypto"
|
"github.com/zitadel/zitadel/internal/crypto"
|
||||||
|
"github.com/zitadel/zitadel/internal/domain"
|
||||||
"github.com/zitadel/zitadel/internal/query"
|
"github.com/zitadel/zitadel/internal/query"
|
||||||
|
"github.com/zitadel/zitadel/internal/zerrors"
|
||||||
)
|
)
|
||||||
|
|
||||||
type UsersHandler struct {
|
type UsersHandler struct {
|
||||||
command *command.Commands
|
command *command.Commands
|
||||||
query *query.Queries
|
query *query.Queries
|
||||||
userCodeAlg crypto.EncryptionAlgorithm
|
userCodeAlg crypto.EncryptionAlgorithm
|
||||||
config *scim_config.Config
|
config *scim_config.Config
|
||||||
|
filterEvaluator *filter.Evaluator
|
||||||
}
|
}
|
||||||
|
|
||||||
type ScimUser struct {
|
type ScimUser struct {
|
||||||
@ -105,7 +110,7 @@ func NewUsersHandler(
|
|||||||
query *query.Queries,
|
query *query.Queries,
|
||||||
userCodeAlg crypto.EncryptionAlgorithm,
|
userCodeAlg crypto.EncryptionAlgorithm,
|
||||||
config *scim_config.Config) ResourceHandler[*ScimUser] {
|
config *scim_config.Config) ResourceHandler[*ScimUser] {
|
||||||
return &UsersHandler{command, query, userCodeAlg, config}
|
return &UsersHandler{command, query, userCodeAlg, config, filter.NewEvaluator(scim_schemas.IdUser)}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *UsersHandler) ResourceNameSingular() scim_schemas.ScimResourceTypeSingular {
|
func (h *UsersHandler) ResourceNameSingular() scim_schemas.ScimResourceTypeSingular {
|
||||||
@ -160,6 +165,21 @@ func (h *UsersHandler) Replace(ctx context.Context, id string, user *ScimUser) (
|
|||||||
return user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *UsersHandler) Update(ctx context.Context, id string, operations patch.OperationCollection) error {
|
||||||
|
userWM, err := h.command.UserHumanWriteModel(ctx, id, true, true, true, true, false, false, true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
user := h.mapWriteModelToScimUser(ctx, userWM)
|
||||||
|
changeHuman, err := h.applyPatchesToChangeHuman(ctx, user, operations)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return h.command.ChangeUserHuman(ctx, changeHuman, h.userCodeAlg)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *UsersHandler) Delete(ctx context.Context, id string) error {
|
func (h *UsersHandler) Delete(ctx context.Context, id string) error {
|
||||||
memberships, grants, err := h.queryUserDependencies(ctx, id)
|
memberships, grants, err := h.queryUserDependencies(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -176,6 +196,10 @@ func (h *UsersHandler) Get(ctx context.Context, id string) (*ScimUser, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if user.Type != domain.UserTypeHuman {
|
||||||
|
return nil, zerrors.ThrowNotFound(nil, "SCIM-USRT1", "Errors.Users.NotFound")
|
||||||
|
}
|
||||||
|
|
||||||
metadata, err := h.queryMetadataForUser(ctx, id)
|
metadata, err := h.queryMetadataForUser(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
@ -14,6 +14,7 @@ import (
|
|||||||
"github.com/zitadel/zitadel/internal/command"
|
"github.com/zitadel/zitadel/internal/command"
|
||||||
"github.com/zitadel/zitadel/internal/domain"
|
"github.com/zitadel/zitadel/internal/domain"
|
||||||
"github.com/zitadel/zitadel/internal/query"
|
"github.com/zitadel/zitadel/internal/query"
|
||||||
|
"github.com/zitadel/zitadel/internal/zerrors"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (h *UsersHandler) mapToAddHuman(ctx context.Context, scimUser *ScimUser) (*command.AddHuman, error) {
|
func (h *UsersHandler) mapToAddHuman(ctx context.Context, scimUser *ScimUser) (*command.AddHuman, error) {
|
||||||
@ -27,8 +28,10 @@ func (h *UsersHandler) mapToAddHuman(ctx context.Context, scimUser *ScimUser) (*
|
|||||||
human.SetInactive = true
|
human.SetInactive = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if email := h.mapPrimaryEmail(scimUser); email != nil {
|
if email, err := h.mapPrimaryEmail(scimUser); err != nil {
|
||||||
human.Email = *email
|
return nil, err
|
||||||
|
} else {
|
||||||
|
human.Email = email
|
||||||
}
|
}
|
||||||
|
|
||||||
if phone := h.mapPrimaryPhone(scimUser); phone != nil {
|
if phone := h.mapPrimaryPhone(scimUser); phone != nil {
|
||||||
@ -76,10 +79,19 @@ func (h *UsersHandler) mapToChangeHuman(ctx context.Context, scimUser *ScimUser)
|
|||||||
NickName: &scimUser.NickName,
|
NickName: &scimUser.NickName,
|
||||||
DisplayName: &scimUser.DisplayName,
|
DisplayName: &scimUser.DisplayName,
|
||||||
},
|
},
|
||||||
Email: h.mapPrimaryEmail(scimUser),
|
|
||||||
Phone: h.mapPrimaryPhone(scimUser),
|
Phone: h.mapPrimaryPhone(scimUser),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if human.Phone == nil {
|
||||||
|
human.Phone = &command.Phone{Remove: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
if email, err := h.mapPrimaryEmail(scimUser); err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else {
|
||||||
|
human.Email = &email
|
||||||
|
}
|
||||||
|
|
||||||
if scimUser.Active != nil {
|
if scimUser.Active != nil {
|
||||||
if *scimUser.Active {
|
if *scimUser.Active {
|
||||||
human.State = gu.Ptr(domain.UserStateActive)
|
human.State = gu.Ptr(domain.UserStateActive)
|
||||||
@ -114,6 +126,12 @@ func (h *UsersHandler) mapToChangeHuman(ctx context.Context, scimUser *ScimUser)
|
|||||||
// update user to match the actual stored value
|
// update user to match the actual stored value
|
||||||
scimUser.Name.Formatted = *human.Profile.DisplayName
|
scimUser.Name.Formatted = *human.Profile.DisplayName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if scimUser.Name.GivenName == "" || scimUser.Name.FamilyName == "" {
|
||||||
|
return nil, zerrors.ThrowInvalidArgument(nil, "SCIM-USN1", "The name of a user is mandatory")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return nil, zerrors.ThrowInvalidArgument(nil, "SCIM-USN2", "The name of a user is mandatory")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := domain.LanguageIsDefined(scimUser.PreferredLanguage); err != nil {
|
if err := domain.LanguageIsDefined(scimUser.PreferredLanguage); err != nil {
|
||||||
@ -124,19 +142,19 @@ func (h *UsersHandler) mapToChangeHuman(ctx context.Context, scimUser *ScimUser)
|
|||||||
return human, nil
|
return human, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *UsersHandler) mapPrimaryEmail(scimUser *ScimUser) *command.Email {
|
func (h *UsersHandler) mapPrimaryEmail(scimUser *ScimUser) (command.Email, error) {
|
||||||
for _, email := range scimUser.Emails {
|
for _, email := range scimUser.Emails {
|
||||||
if !email.Primary {
|
if !email.Primary {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
return &command.Email{
|
return command.Email{
|
||||||
Address: domain.EmailAddress(email.Value),
|
Address: domain.EmailAddress(email.Value),
|
||||||
Verified: h.config.EmailVerified,
|
Verified: h.config.EmailVerified,
|
||||||
}
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return command.Email{}, zerrors.ThrowInvalidArgument(nil, "SCIM-EM19", "Errors.User.Email.Empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *UsersHandler) mapPrimaryPhone(scimUser *ScimUser) *command.Phone {
|
func (h *UsersHandler) mapPrimaryPhone(scimUser *ScimUser) *command.Phone {
|
||||||
@ -224,95 +242,126 @@ func (h *UsersHandler) mapToScimUsers(ctx context.Context, users []*query.User,
|
|||||||
|
|
||||||
func (h *UsersHandler) mapToScimUser(ctx context.Context, user *query.User, md map[metadata.ScopedKey][]byte) *ScimUser {
|
func (h *UsersHandler) mapToScimUser(ctx context.Context, user *query.User, md map[metadata.ScopedKey][]byte) *ScimUser {
|
||||||
scimUser := &ScimUser{
|
scimUser := &ScimUser{
|
||||||
Resource: h.buildResourceForQuery(ctx, user),
|
Resource: h.buildResourceForQuery(ctx, user),
|
||||||
ID: user.ID,
|
ID: user.ID,
|
||||||
ExternalID: extractScalarMetadata(ctx, md, metadata.KeyExternalId),
|
UserName: user.Username,
|
||||||
UserName: user.Username,
|
DisplayName: user.Human.DisplayName,
|
||||||
ProfileUrl: extractHttpURLMetadata(ctx, md, metadata.KeyProfileUrl),
|
NickName: user.Human.NickName,
|
||||||
Title: extractScalarMetadata(ctx, md, metadata.KeyTitle),
|
PreferredLanguage: user.Human.PreferredLanguage,
|
||||||
Locale: extractScalarMetadata(ctx, md, metadata.KeyLocale),
|
Name: &ScimUserName{
|
||||||
Timezone: extractScalarMetadata(ctx, md, metadata.KeyTimezone),
|
Formatted: user.Human.DisplayName,
|
||||||
Active: gu.Ptr(user.State.IsEnabled()),
|
FamilyName: user.Human.LastName,
|
||||||
Ims: make([]*ScimIms, 0),
|
GivenName: user.Human.FirstName,
|
||||||
Addresses: make([]*ScimAddress, 0),
|
},
|
||||||
Photos: make([]*ScimPhoto, 0),
|
Active: gu.Ptr(user.State.IsEnabled()),
|
||||||
Entitlements: make([]*ScimEntitlement, 0),
|
|
||||||
Roles: make([]*ScimRole, 0),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if scimUser.Locale != "" {
|
if string(user.Human.Email) != "" {
|
||||||
_, err := language.Parse(scimUser.Locale)
|
scimUser.Emails = []*ScimEmail{
|
||||||
if err != nil {
|
{
|
||||||
logging.OnError(err).Warn("Failed to load locale of scim user")
|
Value: string(user.Human.Email),
|
||||||
scimUser.Locale = ""
|
Primary: true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if scimUser.Timezone != "" {
|
if string(user.Human.Phone) != "" {
|
||||||
_, err := time.LoadLocation(scimUser.Timezone)
|
scimUser.PhoneNumbers = []*ScimPhoneNumber{
|
||||||
if err != nil {
|
{
|
||||||
logging.OnError(err).Warn("Failed to load timezone of scim user")
|
Value: string(user.Human.Phone),
|
||||||
scimUser.Timezone = ""
|
Primary: true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := extractJsonMetadata(ctx, md, metadata.KeyIms, &scimUser.Ims); err != nil {
|
h.mapAndValidateMetadata(ctx, scimUser, md)
|
||||||
logging.OnError(err).Warn("Could not deserialize scim ims metadata")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := extractJsonMetadata(ctx, md, metadata.KeyAddresses, &scimUser.Addresses); err != nil {
|
|
||||||
logging.OnError(err).Warn("Could not deserialize scim addresses metadata")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := extractJsonMetadata(ctx, md, metadata.KeyPhotos, &scimUser.Photos); err != nil {
|
|
||||||
logging.OnError(err).Warn("Could not deserialize scim photos metadata")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := extractJsonMetadata(ctx, md, metadata.KeyEntitlements, &scimUser.Entitlements); err != nil {
|
|
||||||
logging.OnError(err).Warn("Could not deserialize scim entitlements metadata")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := extractJsonMetadata(ctx, md, metadata.KeyRoles, &scimUser.Roles); err != nil {
|
|
||||||
logging.OnError(err).Warn("Could not deserialize scim roles metadata")
|
|
||||||
}
|
|
||||||
|
|
||||||
if user.Human != nil {
|
|
||||||
mapHumanToScimUser(ctx, user.Human, scimUser, md)
|
|
||||||
}
|
|
||||||
|
|
||||||
return scimUser
|
return scimUser
|
||||||
}
|
}
|
||||||
|
|
||||||
func mapHumanToScimUser(ctx context.Context, human *query.Human, user *ScimUser, md map[metadata.ScopedKey][]byte) {
|
func (h *UsersHandler) mapWriteModelToScimUser(ctx context.Context, user *command.UserV2WriteModel) *ScimUser {
|
||||||
user.DisplayName = human.DisplayName
|
scimUser := &ScimUser{
|
||||||
user.NickName = human.NickName
|
Resource: h.buildResourceForWriteModel(ctx, user),
|
||||||
user.PreferredLanguage = human.PreferredLanguage
|
ID: user.AggregateID,
|
||||||
user.Name = &ScimUserName{
|
UserName: user.UserName,
|
||||||
Formatted: human.DisplayName,
|
DisplayName: user.DisplayName,
|
||||||
FamilyName: human.LastName,
|
NickName: user.NickName,
|
||||||
GivenName: human.FirstName,
|
PreferredLanguage: user.PreferredLanguage,
|
||||||
MiddleName: extractScalarMetadata(ctx, md, metadata.KeyMiddleName),
|
Name: &ScimUserName{
|
||||||
HonorificPrefix: extractScalarMetadata(ctx, md, metadata.KeyHonorificPrefix),
|
Formatted: user.DisplayName,
|
||||||
HonorificSuffix: extractScalarMetadata(ctx, md, metadata.KeyHonorificSuffix),
|
FamilyName: user.LastName,
|
||||||
|
GivenName: user.FirstName,
|
||||||
|
},
|
||||||
|
Active: gu.Ptr(user.UserState.IsEnabled()),
|
||||||
}
|
}
|
||||||
|
|
||||||
if string(human.Email) != "" {
|
if string(user.Email) != "" {
|
||||||
user.Emails = []*ScimEmail{
|
scimUser.Emails = []*ScimEmail{
|
||||||
{
|
{
|
||||||
Value: string(human.Email),
|
Value: string(user.Email),
|
||||||
Primary: true,
|
Primary: true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if string(human.Phone) != "" {
|
if string(user.Phone) != "" {
|
||||||
user.PhoneNumbers = []*ScimPhoneNumber{
|
scimUser.PhoneNumbers = []*ScimPhoneNumber{
|
||||||
{
|
{
|
||||||
Value: string(human.Phone),
|
Value: string(user.Phone),
|
||||||
Primary: true,
|
Primary: true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
md := metadata.MapToScopedKeyMap(user.Metadata)
|
||||||
|
h.mapAndValidateMetadata(ctx, scimUser, md)
|
||||||
|
return scimUser
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *UsersHandler) mapAndValidateMetadata(ctx context.Context, user *ScimUser, md map[metadata.ScopedKey][]byte) {
|
||||||
|
user.ExternalID = extractScalarMetadata(ctx, md, metadata.KeyExternalId)
|
||||||
|
user.ProfileUrl = extractHttpURLMetadata(ctx, md, metadata.KeyProfileUrl)
|
||||||
|
user.Title = extractScalarMetadata(ctx, md, metadata.KeyTitle)
|
||||||
|
user.Locale = extractScalarMetadata(ctx, md, metadata.KeyLocale)
|
||||||
|
user.Timezone = extractScalarMetadata(ctx, md, metadata.KeyTimezone)
|
||||||
|
user.Name.MiddleName = extractScalarMetadata(ctx, md, metadata.KeyMiddleName)
|
||||||
|
user.Name.HonorificPrefix = extractScalarMetadata(ctx, md, metadata.KeyHonorificPrefix)
|
||||||
|
user.Name.HonorificSuffix = extractScalarMetadata(ctx, md, metadata.KeyHonorificSuffix)
|
||||||
|
|
||||||
|
if user.Locale != "" {
|
||||||
|
_, err := language.Parse(user.Locale)
|
||||||
|
if err != nil {
|
||||||
|
logging.OnError(err).Warn("Failed to load locale of scim user")
|
||||||
|
user.Locale = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.Timezone != "" {
|
||||||
|
_, err := time.LoadLocation(user.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
logging.OnError(err).Warn("Failed to load timezone of scim user")
|
||||||
|
user.Timezone = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := extractJsonMetadata(ctx, md, metadata.KeyIms, &user.Ims); err != nil {
|
||||||
|
logging.OnError(err).Warn("Could not deserialize scim ims metadata")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := extractJsonMetadata(ctx, md, metadata.KeyAddresses, &user.Addresses); err != nil {
|
||||||
|
logging.OnError(err).Warn("Could not deserialize scim addresses metadata")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := extractJsonMetadata(ctx, md, metadata.KeyPhotos, &user.Photos); err != nil {
|
||||||
|
logging.OnError(err).Warn("Could not deserialize scim photos metadata")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := extractJsonMetadata(ctx, md, metadata.KeyEntitlements, &user.Entitlements); err != nil {
|
||||||
|
logging.OnError(err).Warn("Could not deserialize scim entitlements metadata")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := extractJsonMetadata(ctx, md, metadata.KeyRoles, &user.Roles); err != nil {
|
||||||
|
logging.OnError(err).Warn("Could not deserialize scim roles metadata")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *UsersHandler) buildResourceForQuery(ctx context.Context, user *query.User) *Resource {
|
func (h *UsersHandler) buildResourceForQuery(ctx context.Context, user *query.User) *Resource {
|
||||||
@ -328,6 +377,19 @@ func (h *UsersHandler) buildResourceForQuery(ctx context.Context, user *query.Us
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *UsersHandler) buildResourceForWriteModel(ctx context.Context, user *command.UserV2WriteModel) *Resource {
|
||||||
|
return &Resource{
|
||||||
|
Schemas: []schemas.ScimSchemaType{schemas.IdUser},
|
||||||
|
Meta: &ResourceMeta{
|
||||||
|
ResourceType: schemas.UserResourceType,
|
||||||
|
Created: user.CreationDate.UTC(),
|
||||||
|
LastModified: user.ChangeDate.UTC(),
|
||||||
|
Version: strconv.FormatUint(user.ProcessedSequence, 10),
|
||||||
|
Location: buildLocation(ctx, h, user.AggregateID),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func cascadingMemberships(memberships []*query.Membership) []*command.CascadingMembership {
|
func cascadingMemberships(memberships []*query.Membership) []*command.CascadingMembership {
|
||||||
cascades := make([]*command.CascadingMembership, len(memberships))
|
cascades := make([]*command.CascadingMembership, len(memberships))
|
||||||
for i, membership := range memberships {
|
for i, membership := range memberships {
|
||||||
|
@ -50,12 +50,7 @@ func (h *UsersHandler) queryMetadataForUser(ctx context.Context, id string) (map
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
metadataMap := make(map[metadata.ScopedKey][]byte, len(md.Metadata))
|
return metadata.MapListToScopedKeyMap(md.Metadata), nil
|
||||||
for _, entry := range md.Metadata {
|
|
||||||
metadataMap[metadata.ScopedKey(entry.Key)] = entry.Value
|
|
||||||
}
|
|
||||||
|
|
||||||
return metadataMap, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *UsersHandler) buildMetadataQueries(ctx context.Context) *query.UserMetadataSearchQueries {
|
func (h *UsersHandler) buildMetadataQueries(ctx context.Context) *query.UserMetadataSearchQueries {
|
||||||
|
111
internal/api/scim/resources/user_patch.go
Normal file
111
internal/api/scim/resources/user_patch.go
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
package resources
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/zitadel/zitadel/internal/api/scim/metadata"
|
||||||
|
"github.com/zitadel/zitadel/internal/api/scim/resources/filter"
|
||||||
|
"github.com/zitadel/zitadel/internal/api/scim/resources/patch"
|
||||||
|
"github.com/zitadel/zitadel/internal/command"
|
||||||
|
"github.com/zitadel/zitadel/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type userPatcher struct {
|
||||||
|
ctx context.Context
|
||||||
|
user *ScimUser
|
||||||
|
metadataChanges map[metadata.Key]*domain.Metadata
|
||||||
|
metadataKeysToRemove map[metadata.Key]bool
|
||||||
|
handler *UsersHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *UsersHandler) applyPatchesToChangeHuman(ctx context.Context, user *ScimUser, operations patch.OperationCollection) (*command.ChangeHuman, error) {
|
||||||
|
patcher := &userPatcher{
|
||||||
|
ctx: ctx,
|
||||||
|
user: user,
|
||||||
|
metadataChanges: make(map[metadata.Key]*domain.Metadata),
|
||||||
|
metadataKeysToRemove: make(map[metadata.Key]bool),
|
||||||
|
handler: h,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := operations.Apply(patcher, user); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// we rely on the change detection of the write model to only execute commands that really change data
|
||||||
|
changeCommand, err := h.mapToChangeHuman(ctx, user)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
patcher.applyMetadataChangesToCommand(changeCommand)
|
||||||
|
return changeCommand, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *userPatcher) FilterEvaluator() *filter.Evaluator {
|
||||||
|
return p.handler.filterEvaluator
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *userPatcher) Added(attributePath []string) error {
|
||||||
|
return p.updateMetadata(attributePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *userPatcher) Replaced(attributePath []string) error {
|
||||||
|
return p.updateMetadata(attributePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *userPatcher) Removed(attributePath []string) error {
|
||||||
|
return p.updateMetadata(attributePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *userPatcher) applyMetadataChangesToCommand(command *command.ChangeHuman) {
|
||||||
|
command.MetadataKeysToRemove = make([]string, 0, len(p.metadataKeysToRemove))
|
||||||
|
for key := range p.metadataKeysToRemove {
|
||||||
|
command.MetadataKeysToRemove = append(command.MetadataKeysToRemove, string(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
command.Metadata = make([]*domain.Metadata, 0, len(p.metadataChanges))
|
||||||
|
for _, update := range p.metadataChanges {
|
||||||
|
command.Metadata = append(command.Metadata, update)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *userPatcher) updateMetadata(attributePath []string) error {
|
||||||
|
if len(attributePath) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// try full path first (e.g. name.middleName)
|
||||||
|
// try root only if full path did not match (e.g. for entitlements.value only entitlements is mapped)
|
||||||
|
var ok bool
|
||||||
|
var keys []metadata.Key
|
||||||
|
if len(attributePath) > 1 {
|
||||||
|
keys, ok = metadata.AttributePathToMetadataKeys[strings.Join(attributePath, ".")]
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
keys, ok = metadata.AttributePathToMetadataKeys[attributePath[0]]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, key := range keys {
|
||||||
|
value, err := getValueForMetadataKey(p.user, key)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(value) > 0 {
|
||||||
|
delete(p.metadataKeysToRemove, key)
|
||||||
|
p.metadataChanges[key] = &domain.Metadata{
|
||||||
|
Key: string(metadata.ScopeKey(p.ctx, key)),
|
||||||
|
Value: value,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
p.metadataKeysToRemove[key] = true
|
||||||
|
delete(p.metadataChanges, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
984
internal/api/scim/resources/user_patch_test.go
Normal file
984
internal/api/scim/resources/user_patch_test.go
Normal file
@ -0,0 +1,984 @@
|
|||||||
|
package resources
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"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/metadata"
|
||||||
|
"github.com/zitadel/zitadel/internal/api/scim/resources/filter"
|
||||||
|
"github.com/zitadel/zitadel/internal/api/scim/resources/patch"
|
||||||
|
"github.com/zitadel/zitadel/internal/api/scim/schemas"
|
||||||
|
"github.com/zitadel/zitadel/internal/domain"
|
||||||
|
"github.com/zitadel/zitadel/internal/test"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOperationCollection_Apply(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
op *patch.Operation
|
||||||
|
prepare func(user *ScimUser)
|
||||||
|
want *ScimUser
|
||||||
|
wantFn func(user *ScimUser)
|
||||||
|
wantModifications []string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "add unknown path",
|
||||||
|
op: &patch.Operation{
|
||||||
|
Operation: patch.OperationTypeAdd,
|
||||||
|
Path: test.Must(filter.ParsePath("fooBar")),
|
||||||
|
Value: json.RawMessage(`{ "userName": "hans.muster", "nickname": "hansi" }`),
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "add without path",
|
||||||
|
op: &patch.Operation{
|
||||||
|
Operation: patch.OperationTypeAdd,
|
||||||
|
Value: json.RawMessage(`{ "userName": "hans.muster", "nickname": "hansi" }`),
|
||||||
|
},
|
||||||
|
want: &ScimUser{
|
||||||
|
UserName: "hans.muster",
|
||||||
|
NickName: "hansi",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "add without path (sample from rfc)",
|
||||||
|
op: &patch.Operation{
|
||||||
|
Operation: patch.OperationTypeAdd,
|
||||||
|
Value: json.RawMessage(`
|
||||||
|
{
|
||||||
|
"emails":[
|
||||||
|
{
|
||||||
|
"value":"babs@jensen.org",
|
||||||
|
"type":"home",
|
||||||
|
"primary": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"nickname":"Babs"
|
||||||
|
}`),
|
||||||
|
},
|
||||||
|
want: &ScimUser{
|
||||||
|
NickName: "Babs",
|
||||||
|
Emails: []*ScimEmail{
|
||||||
|
{
|
||||||
|
Value: "jeanie.pendleton@example.com",
|
||||||
|
Primary: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Value: "babs@jensen.org",
|
||||||
|
Primary: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "add with path value which is nil",
|
||||||
|
op: &patch.Operation{
|
||||||
|
Operation: patch.OperationTypeAdd,
|
||||||
|
Path: test.Must(filter.ParsePath("externalid")),
|
||||||
|
Value: json.RawMessage(`"externalid-1"`),
|
||||||
|
},
|
||||||
|
want: &ScimUser{
|
||||||
|
ExternalID: "externalid-1",
|
||||||
|
},
|
||||||
|
wantModifications: []string{"rep:externalid"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "add complex attribute",
|
||||||
|
op: &patch.Operation{
|
||||||
|
Operation: patch.OperationTypeAdd,
|
||||||
|
Path: test.Must(filter.ParsePath("name")),
|
||||||
|
Value: json.RawMessage(`{
|
||||||
|
"Formatted": "added-formatted",
|
||||||
|
"FamilyName": "added-family-name",
|
||||||
|
"GivenName": "added-given-name",
|
||||||
|
"MiddleName": "added-middle-name",
|
||||||
|
"HonorificPrefix": "added-honorific-prefix",
|
||||||
|
"HonorificSuffix": "added-honorific-suffix"
|
||||||
|
}`),
|
||||||
|
},
|
||||||
|
want: &ScimUser{
|
||||||
|
Name: &ScimUserName{
|
||||||
|
Formatted: "added-formatted",
|
||||||
|
FamilyName: "added-family-name",
|
||||||
|
GivenName: "added-given-name",
|
||||||
|
MiddleName: "added-middle-name",
|
||||||
|
HonorificPrefix: "added-honorific-prefix",
|
||||||
|
HonorificSuffix: "added-honorific-suffix",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantModifications: []string{"rep:name"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "add complex attribute value",
|
||||||
|
op: &patch.Operation{
|
||||||
|
Operation: patch.OperationTypeAdd,
|
||||||
|
Path: test.Must(filter.ParsePath("name.formatted")),
|
||||||
|
Value: json.RawMessage(`"added-formatted"`),
|
||||||
|
},
|
||||||
|
want: &ScimUser{
|
||||||
|
Name: &ScimUserName{
|
||||||
|
Formatted: "added-formatted",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantModifications: []string{"rep:name.formatted"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "add single to multi valued empty attribute",
|
||||||
|
op: &patch.Operation{
|
||||||
|
Operation: patch.OperationTypeAdd,
|
||||||
|
Path: test.Must(filter.ParsePath("entitlements")),
|
||||||
|
Value: json.RawMessage(`{
|
||||||
|
"value": "added-entitlement"
|
||||||
|
}`),
|
||||||
|
},
|
||||||
|
prepare: func(user *ScimUser) {
|
||||||
|
user.Entitlements = nil
|
||||||
|
},
|
||||||
|
want: &ScimUser{
|
||||||
|
Entitlements: []*ScimEntitlement{
|
||||||
|
{
|
||||||
|
Value: "added-entitlement",
|
||||||
|
Primary: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantModifications: []string{"add:entitlements"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "add single to multi valued attribute",
|
||||||
|
op: &patch.Operation{
|
||||||
|
Operation: patch.OperationTypeAdd,
|
||||||
|
Path: test.Must(filter.ParsePath("entitlements")),
|
||||||
|
Value: json.RawMessage(`{
|
||||||
|
"value": "added-entitlement"
|
||||||
|
}`),
|
||||||
|
},
|
||||||
|
want: &ScimUser{
|
||||||
|
Entitlements: []*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,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Value: "added-entitlement",
|
||||||
|
Primary: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantModifications: []string{"add:entitlements"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "add single primary to multi valued attribute",
|
||||||
|
op: &patch.Operation{
|
||||||
|
Operation: patch.OperationTypeAdd,
|
||||||
|
Path: test.Must(filter.ParsePath("entitlements")),
|
||||||
|
Value: json.RawMessage(`{
|
||||||
|
"value": "added-entitlement",
|
||||||
|
"primary": true
|
||||||
|
}`),
|
||||||
|
},
|
||||||
|
want: &ScimUser{
|
||||||
|
Entitlements: []*ScimEntitlement{
|
||||||
|
{
|
||||||
|
Value: "my-entitlement-1",
|
||||||
|
Display: "Entitlement 1",
|
||||||
|
Type: "main-entitlement",
|
||||||
|
Primary: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Value: "my-entitlement-2",
|
||||||
|
Display: "Entitlement 2",
|
||||||
|
Type: "secondary-entitlement",
|
||||||
|
Primary: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Value: "added-entitlement",
|
||||||
|
Primary: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantModifications: []string{"add:entitlements"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "add unique valued item in multi valued attribute",
|
||||||
|
op: &patch.Operation{
|
||||||
|
Operation: patch.OperationTypeAdd,
|
||||||
|
Path: test.Must(filter.ParsePath("entitlements")),
|
||||||
|
Value: json.RawMessage(`
|
||||||
|
{
|
||||||
|
"value": "my-entitlement-1",
|
||||||
|
"display": "entitlement-1-patched",
|
||||||
|
"primary": true
|
||||||
|
}`),
|
||||||
|
},
|
||||||
|
want: &ScimUser{
|
||||||
|
Entitlements: []*ScimEntitlement{
|
||||||
|
{
|
||||||
|
Value: "my-entitlement-1",
|
||||||
|
Display: "entitlement-1-patched",
|
||||||
|
Primary: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Value: "my-entitlement-2",
|
||||||
|
Display: "Entitlement 2",
|
||||||
|
Type: "secondary-entitlement",
|
||||||
|
Primary: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantModifications: []string{"add:entitlements"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "add unique valued item and additional item in multi valued attribute",
|
||||||
|
op: &patch.Operation{
|
||||||
|
Operation: patch.OperationTypeAdd,
|
||||||
|
Path: test.Must(filter.ParsePath("entitlements")),
|
||||||
|
Value: json.RawMessage(`[
|
||||||
|
{
|
||||||
|
"value": "my-entitlement-1",
|
||||||
|
"display": "entitlement-1-patched",
|
||||||
|
"primary": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "my-entitlement-3",
|
||||||
|
"display": "entitlement-3",
|
||||||
|
"primary": false
|
||||||
|
}
|
||||||
|
]`),
|
||||||
|
},
|
||||||
|
want: &ScimUser{
|
||||||
|
Entitlements: []*ScimEntitlement{
|
||||||
|
{
|
||||||
|
Value: "my-entitlement-1",
|
||||||
|
Display: "entitlement-1-patched",
|
||||||
|
Primary: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Value: "my-entitlement-2",
|
||||||
|
Display: "Entitlement 2",
|
||||||
|
Type: "secondary-entitlement",
|
||||||
|
Primary: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Value: "my-entitlement-3",
|
||||||
|
Display: "entitlement-3",
|
||||||
|
Primary: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantModifications: []string{"add:entitlements"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "add unique valued items in multi valued attribute",
|
||||||
|
op: &patch.Operation{
|
||||||
|
Operation: patch.OperationTypeAdd,
|
||||||
|
Path: test.Must(filter.ParsePath("entitlements")),
|
||||||
|
Value: json.RawMessage(`[
|
||||||
|
{
|
||||||
|
"value": "my-entitlement-1",
|
||||||
|
"display": "entitlement-1-patched",
|
||||||
|
"primary": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "my-entitlement-2",
|
||||||
|
"display": "entitlement-2-patched",
|
||||||
|
"primary": false
|
||||||
|
}
|
||||||
|
]`),
|
||||||
|
},
|
||||||
|
want: &ScimUser{
|
||||||
|
Entitlements: []*ScimEntitlement{
|
||||||
|
{
|
||||||
|
Value: "my-entitlement-1",
|
||||||
|
Display: "entitlement-1-patched",
|
||||||
|
Primary: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Value: "my-entitlement-2",
|
||||||
|
Display: "entitlement-2-patched",
|
||||||
|
Primary: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantModifications: []string{"add:entitlements"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "add multiple to multi valued attribute",
|
||||||
|
op: &patch.Operation{
|
||||||
|
Operation: patch.OperationTypeAdd,
|
||||||
|
Path: test.Must(filter.ParsePath("entitlements")),
|
||||||
|
Value: json.RawMessage(` [
|
||||||
|
{
|
||||||
|
"value": "added-entitlement",
|
||||||
|
"primary": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "added-entitlement-2"
|
||||||
|
}
|
||||||
|
]`),
|
||||||
|
},
|
||||||
|
want: &ScimUser{
|
||||||
|
Entitlements: []*ScimEntitlement{
|
||||||
|
{
|
||||||
|
Value: "my-entitlement-1",
|
||||||
|
Display: "Entitlement 1",
|
||||||
|
Type: "main-entitlement",
|
||||||
|
Primary: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Value: "my-entitlement-2",
|
||||||
|
Display: "Entitlement 2",
|
||||||
|
Type: "secondary-entitlement",
|
||||||
|
Primary: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Value: "added-entitlement",
|
||||||
|
Primary: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Value: "added-entitlement-2",
|
||||||
|
Primary: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantModifications: []string{"add:entitlements"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "add multiple primaries to multi valued attribute",
|
||||||
|
op: &patch.Operation{
|
||||||
|
Operation: patch.OperationTypeAdd,
|
||||||
|
Path: test.Must(filter.ParsePath("entitlements")),
|
||||||
|
Value: json.RawMessage(` [
|
||||||
|
{
|
||||||
|
"value": "added-entitlement",
|
||||||
|
"primary": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "added-entitlement-2",
|
||||||
|
"primary": true
|
||||||
|
}
|
||||||
|
]`),
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "remove unknown path",
|
||||||
|
op: &patch.Operation{
|
||||||
|
Operation: patch.OperationTypeReplace,
|
||||||
|
Path: test.Must(filter.ParsePath("fooBar")),
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "remove without a path",
|
||||||
|
op: &patch.Operation{
|
||||||
|
Operation: patch.OperationTypeRemove,
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "remove single valued attribute",
|
||||||
|
op: &patch.Operation{
|
||||||
|
Operation: patch.OperationTypeRemove,
|
||||||
|
Path: test.Must(filter.ParsePath("nickname")),
|
||||||
|
},
|
||||||
|
wantFn: func(user *ScimUser) {
|
||||||
|
assert.Equal(t, user.NickName, "")
|
||||||
|
},
|
||||||
|
wantModifications: []string{"rem:nickname"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "remove multi valued attribute",
|
||||||
|
op: &patch.Operation{
|
||||||
|
Operation: patch.OperationTypeRemove,
|
||||||
|
Path: test.Must(filter.ParsePath("entitlements")),
|
||||||
|
},
|
||||||
|
wantFn: func(user *ScimUser) {
|
||||||
|
assert.Len(t, user.Entitlements, 0)
|
||||||
|
},
|
||||||
|
wantModifications: []string{"rem:entitlements"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "remove multi valued attribute with filter",
|
||||||
|
op: &patch.Operation{
|
||||||
|
Operation: patch.OperationTypeRemove,
|
||||||
|
Path: test.Must(filter.ParsePath(`entitlements[display ew "1"]`)),
|
||||||
|
},
|
||||||
|
want: &ScimUser{
|
||||||
|
Entitlements: []*ScimEntitlement{
|
||||||
|
{
|
||||||
|
Value: "my-entitlement-2",
|
||||||
|
Display: "Entitlement 2",
|
||||||
|
Type: "secondary-entitlement",
|
||||||
|
Primary: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantModifications: []string{"rem:entitlements"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "remove multi valued attribute with filter matches all",
|
||||||
|
op: &patch.Operation{
|
||||||
|
Operation: patch.OperationTypeRemove,
|
||||||
|
Path: test.Must(filter.ParsePath(`entitlements[value pr]`)),
|
||||||
|
},
|
||||||
|
wantFn: func(user *ScimUser) {
|
||||||
|
assert.Len(t, user.Entitlements, 0)
|
||||||
|
},
|
||||||
|
wantModifications: []string{"rem:entitlements"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "remove attribute of multi valued attribute with filter",
|
||||||
|
op: &patch.Operation{
|
||||||
|
Operation: patch.OperationTypeRemove,
|
||||||
|
Path: test.Must(filter.ParsePath(`entitlements[display ew "1"].display`)),
|
||||||
|
},
|
||||||
|
want: &ScimUser{
|
||||||
|
Entitlements: []*ScimEntitlement{
|
||||||
|
{
|
||||||
|
Value: "my-entitlement-1",
|
||||||
|
Display: "",
|
||||||
|
Type: "main-entitlement",
|
||||||
|
Primary: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Value: "my-entitlement-2",
|
||||||
|
Display: "Entitlement 2",
|
||||||
|
Type: "secondary-entitlement",
|
||||||
|
Primary: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantModifications: []string{"rem:entitlements.display"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// this should not fail
|
||||||
|
// according to the rfc only the replace operation fails without a matching target
|
||||||
|
name: "remove multi valued attribute with filter and no matches",
|
||||||
|
op: &patch.Operation{
|
||||||
|
Operation: patch.OperationTypeRemove,
|
||||||
|
Path: test.Must(filter.ParsePath(`entitlements[display eq "FOOBAR"]`)),
|
||||||
|
},
|
||||||
|
want: &ScimUser{
|
||||||
|
Entitlements: []*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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "replace unknown path",
|
||||||
|
op: &patch.Operation{
|
||||||
|
Operation: patch.OperationTypeReplace,
|
||||||
|
Path: test.Must(filter.ParsePath("fooBar")),
|
||||||
|
Value: json.RawMessage(`"fooBar"`),
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "replace without path",
|
||||||
|
op: &patch.Operation{
|
||||||
|
Operation: patch.OperationTypeReplace,
|
||||||
|
Value: json.RawMessage(`{ "userName": "hans.muster", "nickname": "hansi" }`),
|
||||||
|
},
|
||||||
|
want: &ScimUser{
|
||||||
|
UserName: "hans.muster",
|
||||||
|
NickName: "hansi",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "replace single valued attribute with path",
|
||||||
|
op: &patch.Operation{
|
||||||
|
Operation: patch.OperationTypeReplace,
|
||||||
|
Path: test.Must(filter.ParsePath("nickname")),
|
||||||
|
Value: json.RawMessage(`"fooBar"`),
|
||||||
|
},
|
||||||
|
want: &ScimUser{
|
||||||
|
NickName: "fooBar",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "replace with path value which is nil",
|
||||||
|
op: &patch.Operation{
|
||||||
|
Operation: patch.OperationTypeReplace,
|
||||||
|
Path: test.Must(filter.ParsePath("externalid")),
|
||||||
|
Value: json.RawMessage(`"externalid-1"`),
|
||||||
|
},
|
||||||
|
want: &ScimUser{
|
||||||
|
ExternalID: "externalid-1",
|
||||||
|
},
|
||||||
|
wantModifications: []string{"rep:externalid"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "replace complex attribute",
|
||||||
|
op: &patch.Operation{
|
||||||
|
Operation: patch.OperationTypeReplace,
|
||||||
|
Path: test.Must(filter.ParsePath("name")),
|
||||||
|
Value: json.RawMessage(`{
|
||||||
|
"Formatted": "added-formatted",
|
||||||
|
"FamilyName": "added-family-name",
|
||||||
|
"GivenName": "added-given-name",
|
||||||
|
"MiddleName": "added-middle-name",
|
||||||
|
"HonorificPrefix": "added-honorific-prefix",
|
||||||
|
"HonorificSuffix": "added-honorific-suffix"
|
||||||
|
}`),
|
||||||
|
},
|
||||||
|
want: &ScimUser{
|
||||||
|
Name: &ScimUserName{
|
||||||
|
Formatted: "added-formatted",
|
||||||
|
FamilyName: "added-family-name",
|
||||||
|
GivenName: "added-given-name",
|
||||||
|
MiddleName: "added-middle-name",
|
||||||
|
HonorificPrefix: "added-honorific-prefix",
|
||||||
|
HonorificSuffix: "added-honorific-suffix",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantModifications: []string{"rep:name"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "replace complex multi attribute",
|
||||||
|
op: &patch.Operation{
|
||||||
|
Operation: patch.OperationTypeReplace,
|
||||||
|
Path: test.Must(filter.ParsePath("entitlements")),
|
||||||
|
Value: json.RawMessage(`{
|
||||||
|
"value": "entitlement patched"
|
||||||
|
}`),
|
||||||
|
},
|
||||||
|
want: &ScimUser{
|
||||||
|
Entitlements: []*ScimEntitlement{
|
||||||
|
{
|
||||||
|
Value: "entitlement patched",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantModifications: []string{"rep:entitlements"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "replace complex multi attribute with multiple",
|
||||||
|
op: &patch.Operation{
|
||||||
|
Operation: patch.OperationTypeReplace,
|
||||||
|
Path: test.Must(filter.ParsePath("entitlements")),
|
||||||
|
Value: json.RawMessage(`[
|
||||||
|
{
|
||||||
|
"value": "entitlement patched"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "entitlement patched2"
|
||||||
|
}
|
||||||
|
]`),
|
||||||
|
},
|
||||||
|
want: &ScimUser{
|
||||||
|
Entitlements: []*ScimEntitlement{
|
||||||
|
{
|
||||||
|
Value: "entitlement patched",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Value: "entitlement patched2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantModifications: []string{"rep:entitlements"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "replace complex multi attribute with multiple primary",
|
||||||
|
op: &patch.Operation{
|
||||||
|
Operation: patch.OperationTypeReplace,
|
||||||
|
Path: test.Must(filter.ParsePath("entitlements")),
|
||||||
|
Value: json.RawMessage(`[
|
||||||
|
{
|
||||||
|
"value": "entitlement patched",
|
||||||
|
"primary": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "entitlement patched2",
|
||||||
|
"primary": true
|
||||||
|
}
|
||||||
|
]`),
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "replace filter no match",
|
||||||
|
op: &patch.Operation{
|
||||||
|
Operation: patch.OperationTypeReplace,
|
||||||
|
Path: test.Must(filter.ParsePath(`entitlements[value eq "foobar"]`)),
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "replace filter complex subattribute",
|
||||||
|
op: &patch.Operation{
|
||||||
|
Operation: patch.OperationTypeReplace,
|
||||||
|
Path: test.Must(filter.ParsePath(`entitlements[value eq "my-entitlement-1"].display`)),
|
||||||
|
Value: json.RawMessage(`"updated display"`),
|
||||||
|
},
|
||||||
|
want: &ScimUser{
|
||||||
|
Entitlements: []*ScimEntitlement{
|
||||||
|
{
|
||||||
|
Value: "my-entitlement-1",
|
||||||
|
Display: "updated display",
|
||||||
|
Type: "main-entitlement",
|
||||||
|
Primary: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantModifications: []string{"rep:entitlements.display"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "replace filter complex subattribute primary",
|
||||||
|
op: &patch.Operation{
|
||||||
|
Operation: patch.OperationTypeReplace,
|
||||||
|
Path: test.Must(filter.ParsePath(`entitlements[value eq "my-entitlement-2"].primary`)),
|
||||||
|
Value: json.RawMessage(`true`),
|
||||||
|
},
|
||||||
|
want: &ScimUser{
|
||||||
|
Entitlements: []*ScimEntitlement{
|
||||||
|
{
|
||||||
|
Value: "my-entitlement-1",
|
||||||
|
Display: "Entitlement 1",
|
||||||
|
Type: "main-entitlement",
|
||||||
|
Primary: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Value: "my-entitlement-2",
|
||||||
|
Display: "Entitlement 2",
|
||||||
|
Type: "secondary-entitlement",
|
||||||
|
Primary: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantModifications: []string{"rep:entitlements.primary"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "replace filter complex subattribute multiple primary",
|
||||||
|
op: &patch.Operation{
|
||||||
|
Operation: patch.OperationTypeReplace,
|
||||||
|
Path: test.Must(filter.ParsePath(`roles[primary ne true].primary`)),
|
||||||
|
Value: json.RawMessage(`true`),
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
user := &ScimUser{
|
||||||
|
ID: "1",
|
||||||
|
UserName: "username-1",
|
||||||
|
Name: &ScimUserName{
|
||||||
|
Formatted: "Dr. Jeanie R. Pendleton III",
|
||||||
|
FamilyName: "Pendleton",
|
||||||
|
GivenName: "Jeanie",
|
||||||
|
MiddleName: "Rebecca",
|
||||||
|
HonorificPrefix: "Dr.",
|
||||||
|
HonorificSuffix: "III",
|
||||||
|
},
|
||||||
|
DisplayName: "Jeanie Pendleton",
|
||||||
|
NickName: "Jenny",
|
||||||
|
Title: "Ms.",
|
||||||
|
ProfileUrl: test.Must(schemas.ParseHTTPURL("https://example.com/profile.gif")),
|
||||||
|
PreferredLanguage: language.MustParse("en-US"),
|
||||||
|
Locale: "en-US",
|
||||||
|
Timezone: "America/New_York",
|
||||||
|
Active: gu.Ptr(true),
|
||||||
|
Emails: []*ScimEmail{
|
||||||
|
{
|
||||||
|
Value: "jeanie.pendleton@example.com",
|
||||||
|
Primary: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
PhoneNumbers: []*ScimPhoneNumber{
|
||||||
|
{
|
||||||
|
Value: "+1 775-599-5252",
|
||||||
|
Primary: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Ims: []*ScimIms{
|
||||||
|
{
|
||||||
|
Value: "jeeeeeny91",
|
||||||
|
Type: "icq",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Addresses: []*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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Photos: []*ScimPhoto{
|
||||||
|
{
|
||||||
|
Value: *test.Must(schemas.ParseHTTPURL("https://photos.example.com/profilephoto/72930000000Ccne/F")),
|
||||||
|
Type: "photo",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Value: *test.Must(schemas.ParseHTTPURL("https://photos.example.com/profilephoto/72930000000Ccne/T")),
|
||||||
|
Type: "thumbnail",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Roles: []*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,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Value: "my-role-3",
|
||||||
|
Display: "Rolle 3",
|
||||||
|
Type: "third-role",
|
||||||
|
Primary: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Entitlements: []*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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if tt.prepare != nil {
|
||||||
|
tt.prepare(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
patcher := new(simplePatcher)
|
||||||
|
operations := patch.OperationCollection{tt.op}
|
||||||
|
err := operations.Apply(patcher, user)
|
||||||
|
if tt.wantErr {
|
||||||
|
require.Error(t, err)
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !test.PartiallyDeepEqual(tt.want, user) {
|
||||||
|
t.Errorf("apply() got = %#v, want %#v", user, tt.want)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tt.wantModifications != nil {
|
||||||
|
assert.EqualValues(t, tt.wantModifications, patcher.modifications)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tt.wantFn != nil {
|
||||||
|
tt.wantFn(user)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_userPatcher_updateMetadata(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
metadataPath []string
|
||||||
|
metadataChanges map[metadata.Key]*domain.Metadata
|
||||||
|
metadataKeysToRemove map[metadata.Key]bool
|
||||||
|
wantErr bool
|
||||||
|
wantMetadataChanges map[metadata.Key]*domain.Metadata
|
||||||
|
wantMetadataKeysToRemove map[metadata.Key]bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty path",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown attribute",
|
||||||
|
metadataPath: []string{"fooBar"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown nested attribute",
|
||||||
|
metadataPath: []string{"foo", "bar"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "simple attribute",
|
||||||
|
metadataPath: []string{"title"},
|
||||||
|
wantMetadataChanges: map[metadata.Key]*domain.Metadata{
|
||||||
|
metadata.KeyTitle: {
|
||||||
|
Key: string(metadata.KeyTitle),
|
||||||
|
Value: []byte("Mr."),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "simple attribute with previous deletion",
|
||||||
|
metadataPath: []string{"title"},
|
||||||
|
metadataKeysToRemove: map[metadata.Key]bool{
|
||||||
|
metadata.KeyTitle: true,
|
||||||
|
},
|
||||||
|
wantMetadataChanges: map[metadata.Key]*domain.Metadata{
|
||||||
|
metadata.KeyTitle: {
|
||||||
|
Key: string(metadata.KeyTitle),
|
||||||
|
Value: []byte("Mr."),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nested attribute",
|
||||||
|
metadataPath: []string{"name", "middlename"},
|
||||||
|
wantMetadataChanges: map[metadata.Key]*domain.Metadata{
|
||||||
|
metadata.KeyMiddleName: {
|
||||||
|
Key: string(metadata.KeyMiddleName),
|
||||||
|
Value: []byte("middle name"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nested attribute with previous deletion",
|
||||||
|
metadataPath: []string{"name", "middlename"},
|
||||||
|
metadataKeysToRemove: map[metadata.Key]bool{
|
||||||
|
metadata.KeyMiddleName: true,
|
||||||
|
},
|
||||||
|
wantMetadataChanges: map[metadata.Key]*domain.Metadata{
|
||||||
|
metadata.KeyMiddleName: {
|
||||||
|
Key: string(metadata.KeyMiddleName),
|
||||||
|
Value: []byte("middle name"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "complex object root",
|
||||||
|
metadataPath: []string{"name"},
|
||||||
|
wantMetadataChanges: map[metadata.Key]*domain.Metadata{
|
||||||
|
metadata.KeyMiddleName: {
|
||||||
|
Key: string(metadata.KeyMiddleName),
|
||||||
|
Value: []byte("middle name"),
|
||||||
|
},
|
||||||
|
metadata.KeyHonorificPrefix: {
|
||||||
|
Key: string(metadata.KeyHonorificPrefix),
|
||||||
|
Value: []byte("Adel"),
|
||||||
|
},
|
||||||
|
metadata.KeyHonorificSuffix: {
|
||||||
|
Key: string(metadata.KeyHonorificSuffix),
|
||||||
|
Value: []byte("III"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "delete previous modified attribute",
|
||||||
|
metadataPath: []string{"locale"},
|
||||||
|
metadataChanges: map[metadata.Key]*domain.Metadata{
|
||||||
|
metadata.KeyLocale: {
|
||||||
|
Key: string(metadata.KeyLocale),
|
||||||
|
Value: []byte("edited locale"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantMetadataKeysToRemove: map[metadata.Key]bool{
|
||||||
|
metadata.KeyLocale: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
p := &userPatcher{
|
||||||
|
ctx: context.Background(),
|
||||||
|
user: &ScimUser{
|
||||||
|
Title: "Mr.",
|
||||||
|
Name: &ScimUserName{
|
||||||
|
MiddleName: "middle name",
|
||||||
|
HonorificPrefix: "Adel",
|
||||||
|
HonorificSuffix: "III",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
metadataChanges: tt.metadataChanges,
|
||||||
|
metadataKeysToRemove: tt.metadataKeysToRemove,
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.metadataChanges == nil {
|
||||||
|
p.metadataChanges = make(map[metadata.Key]*domain.Metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.metadataKeysToRemove == nil {
|
||||||
|
p.metadataKeysToRemove = make(map[metadata.Key]bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := p.updateMetadata(tt.metadataPath)
|
||||||
|
if tt.wantErr {
|
||||||
|
require.Error(t, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
if tt.wantMetadataChanges != nil {
|
||||||
|
if !reflect.DeepEqual(p.metadataChanges, tt.wantMetadataChanges) {
|
||||||
|
t.Errorf("updateMetadata() got = %#v, want %#v", p.metadataChanges, tt.wantMetadataChanges)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if tt.metadataKeysToRemove != nil {
|
||||||
|
if !reflect.DeepEqual(p.metadataKeysToRemove, tt.metadataKeysToRemove) {
|
||||||
|
t.Errorf("updateMetadata() got = %#v, want %#v", p.metadataKeysToRemove, tt.metadataKeysToRemove)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type simplePatcher struct {
|
||||||
|
modifications []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *simplePatcher) FilterEvaluator() *filter.Evaluator {
|
||||||
|
return filter.NewEvaluator(schemas.IdUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *simplePatcher) Added(attributePath []string) error {
|
||||||
|
return s.modified("add", attributePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *simplePatcher) Replaced(attributePath []string) error {
|
||||||
|
return s.modified("rep", attributePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *simplePatcher) Removed(attributePath []string) error {
|
||||||
|
return s.modified("rem", attributePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *simplePatcher) modified(op string, attributePath []string) error {
|
||||||
|
s.modifications = append(s.modifications, op+":"+strings.Join(attributePath, "."))
|
||||||
|
return nil
|
||||||
|
}
|
@ -11,6 +11,7 @@ const (
|
|||||||
|
|
||||||
IdUser ScimSchemaType = idPrefixCore + "User"
|
IdUser ScimSchemaType = idPrefixCore + "User"
|
||||||
IdListResponse ScimSchemaType = idPrefixMessages + "ListResponse"
|
IdListResponse ScimSchemaType = idPrefixMessages + "ListResponse"
|
||||||
|
IdPatchOperation ScimSchemaType = idPrefixMessages + "PatchOp"
|
||||||
IdError ScimSchemaType = idPrefixMessages + "Error"
|
IdError ScimSchemaType = idPrefixMessages + "Error"
|
||||||
IdZitadelErrorDetail ScimSchemaType = idPrefixZitadelMessages + "ErrorDetail"
|
IdZitadelErrorDetail ScimSchemaType = idPrefixZitadelMessages + "ErrorDetail"
|
||||||
|
|
||||||
|
@ -54,6 +54,14 @@ const (
|
|||||||
// specified attribute and filter comparison combination is not supported.
|
// specified attribute and filter comparison combination is not supported.
|
||||||
ScimTypeInvalidFilter scimErrorType = "invalidFilter"
|
ScimTypeInvalidFilter scimErrorType = "invalidFilter"
|
||||||
|
|
||||||
|
// ScimTypeInvalidPath The "path" attribute was invalid or malformed.
|
||||||
|
ScimTypeInvalidPath scimErrorType = "invalidPath"
|
||||||
|
|
||||||
|
// ScimTypeNoTarget The specified "path" did not
|
||||||
|
// yield an attribute or attribute value that could be operated on.
|
||||||
|
// This occurs when the specified "path" value contains a filter that yields no match.
|
||||||
|
ScimTypeNoTarget scimErrorType = "noTarget"
|
||||||
|
|
||||||
// ScimTypeUniqueness One or more of the attribute values are already in use or are reserved.
|
// ScimTypeUniqueness One or more of the attribute values are already in use or are reserved.
|
||||||
ScimTypeUniqueness scimErrorType = "uniqueness"
|
ScimTypeUniqueness scimErrorType = "uniqueness"
|
||||||
)
|
)
|
||||||
@ -99,6 +107,20 @@ func ThrowInvalidFilter(parent error) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ThrowInvalidPath(parent error) error {
|
||||||
|
return &wrappedScimError{
|
||||||
|
Parent: parent,
|
||||||
|
ScimType: ScimTypeInvalidPath,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ThrowNoTarget(parent error) error {
|
||||||
|
return &wrappedScimError{
|
||||||
|
Parent: parent,
|
||||||
|
ScimType: ScimTypeNoTarget,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func IsScimOrZitadelError(err error) bool {
|
func IsScimOrZitadelError(err error) bool {
|
||||||
return IsScimError(err) || zerrors.IsZitadelError(err)
|
return IsScimError(err) || zerrors.IsZitadelError(err)
|
||||||
}
|
}
|
||||||
|
@ -58,6 +58,7 @@ func mapResource[T sresources.ResourceHolder](router *mux.Router, mw zhttp_middl
|
|||||||
resourceRouter.Handle("/.search", mw(handleJsonResponse(adapter.List))).Methods(http.MethodPost)
|
resourceRouter.Handle("/.search", mw(handleJsonResponse(adapter.List))).Methods(http.MethodPost)
|
||||||
resourceRouter.Handle("/{id}", mw(handleResourceResponse(adapter.Get))).Methods(http.MethodGet)
|
resourceRouter.Handle("/{id}", mw(handleResourceResponse(adapter.Get))).Methods(http.MethodGet)
|
||||||
resourceRouter.Handle("/{id}", mw(handleResourceResponse(adapter.Replace))).Methods(http.MethodPut)
|
resourceRouter.Handle("/{id}", mw(handleResourceResponse(adapter.Replace))).Methods(http.MethodPut)
|
||||||
|
resourceRouter.Handle("/{id}", mw(handleEmptyResponse(adapter.Update))).Methods(http.MethodPatch)
|
||||||
resourceRouter.Handle("/{id}", mw(handleEmptyResponse(adapter.Delete))).Methods(http.MethodDelete)
|
resourceRouter.Handle("/{id}", mw(handleEmptyResponse(adapter.Delete))).Methods(http.MethodDelete)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -6,16 +6,32 @@ import (
|
|||||||
"github.com/zitadel/zitadel/internal/command/preparation"
|
"github.com/zitadel/zitadel/internal/command/preparation"
|
||||||
"github.com/zitadel/zitadel/internal/crypto"
|
"github.com/zitadel/zitadel/internal/crypto"
|
||||||
"github.com/zitadel/zitadel/internal/domain"
|
"github.com/zitadel/zitadel/internal/domain"
|
||||||
|
"github.com/zitadel/zitadel/internal/zerrors"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Phone struct {
|
type Phone struct {
|
||||||
Number domain.PhoneNumber
|
Number domain.PhoneNumber
|
||||||
|
Remove bool
|
||||||
Verified bool
|
Verified bool
|
||||||
|
|
||||||
// ReturnCode is used if the Verified field is false
|
// ReturnCode is used if the Verified field is false
|
||||||
ReturnCode bool
|
ReturnCode bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *Phone) Validate() (err error) {
|
||||||
|
if p.Remove && p.Number != "" {
|
||||||
|
return zerrors.ThrowInvalidArgumentf(nil, "USRP2-12881", "Cannot update and remove the phone number at the same time")
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.Number != "" {
|
||||||
|
if p.Number, err = p.Number.Normalize(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// newPhoneCode generates a new code to be sent out to via SMS or
|
// newPhoneCode generates a new code to be sent out to via SMS or
|
||||||
// returns the ID of the external code provider (e.g. when using Twilio verification API)
|
// returns the ID of the external code provider (e.g. when using Twilio verification API)
|
||||||
func (c *Commands) newPhoneCode(ctx context.Context, filter preparation.FilterToQueryReducer, secretGeneratorType domain.SecretGeneratorType, alg crypto.EncryptionAlgorithm, defaultConfig *crypto.GeneratorConfig) (*EncryptedCode, string, error) {
|
func (c *Commands) newPhoneCode(ctx context.Context, filter preparation.FilterToQueryReducer, secretGeneratorType domain.SecretGeneratorType, alg crypto.EncryptionAlgorithm, defaultConfig *crypto.GeneratorConfig) (*EncryptedCode, string, error) {
|
||||||
|
@ -14,12 +14,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type ChangeHuman struct {
|
type ChangeHuman struct {
|
||||||
ID string
|
ID string
|
||||||
State *domain.UserState
|
State *domain.UserState
|
||||||
Username *string
|
Username *string
|
||||||
Profile *Profile
|
Profile *Profile
|
||||||
Email *Email
|
Email *Email
|
||||||
Phone *Phone
|
Phone *Phone
|
||||||
|
|
||||||
Metadata []*domain.Metadata
|
Metadata []*domain.Metadata
|
||||||
MetadataKeysToRemove []string
|
MetadataKeysToRemove []string
|
||||||
|
|
||||||
@ -61,8 +62,8 @@ func (h *ChangeHuman) Validate(hasher *crypto.Hasher) (err error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if h.Phone != nil && h.Phone.Number != "" {
|
if h.Phone != nil {
|
||||||
if h.Phone.Number, err = h.Phone.Number.Normalize(); err != nil {
|
if err := h.Phone.Validate(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -263,7 +264,7 @@ func (c *Commands) ChangeUserHuman(ctx context.Context, human *ChangeHuman, alg
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
existingHuman, err := c.userHumanWriteModel(
|
existingHuman, err := c.UserHumanWriteModel(
|
||||||
ctx,
|
ctx,
|
||||||
human.ID,
|
human.ID,
|
||||||
human.Profile != nil,
|
human.Profile != nil,
|
||||||
@ -272,13 +273,11 @@ func (c *Commands) ChangeUserHuman(ctx context.Context, human *ChangeHuman, alg
|
|||||||
human.Password != nil,
|
human.Password != nil,
|
||||||
false, // avatar not updateable
|
false, // avatar not updateable
|
||||||
false, // IDPLinks not updateable
|
false, // IDPLinks not updateable
|
||||||
|
len(human.Metadata) > 0 || len(human.MetadataKeysToRemove) > 0,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !isUserStateExists(existingHuman.UserState) {
|
|
||||||
return zerrors.ThrowNotFound(nil, "COMMAND-ugjs0upun6", "Errors.User.NotFound")
|
|
||||||
}
|
|
||||||
|
|
||||||
if human.Changed() {
|
if human.Changed() {
|
||||||
if err := c.checkPermissionUpdateUser(ctx, existingHuman.ResourceOwner, existingHuman.AggregateID); err != nil {
|
if err := c.checkPermissionUpdateUser(ctx, existingHuman.ResourceOwner, existingHuman.AggregateID); err != nil {
|
||||||
@ -415,6 +414,10 @@ func (c *Commands) changeUserPhone(ctx context.Context, cmds []eventstore.Comman
|
|||||||
ctx, span := tracing.NewSpan(ctx)
|
ctx, span := tracing.NewSpan(ctx)
|
||||||
defer func() { span.End() }()
|
defer func() { span.End() }()
|
||||||
|
|
||||||
|
if phone.Remove {
|
||||||
|
return append(cmds, user.NewHumanPhoneRemovedEvent(ctx, &wm.Aggregate().Aggregate)), nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
if phone.Number != "" && phone.Number != wm.Phone {
|
if phone.Number != "" && phone.Number != wm.Phone {
|
||||||
cmds = append(cmds, user.NewHumanPhoneChangedEvent(ctx, &wm.Aggregate().Aggregate, phone.Number))
|
cmds = append(cmds, user.NewHumanPhoneChangedEvent(ctx, &wm.Aggregate().Aggregate, phone.Number))
|
||||||
|
|
||||||
@ -432,6 +435,7 @@ func (c *Commands) changeUserPhone(ctx context.Context, cmds []eventstore.Comman
|
|||||||
return cmds, code, nil
|
return cmds, code, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// only create separate event of verified if email was not changed
|
// only create separate event of verified if email was not changed
|
||||||
if phone.Verified && wm.IsPhoneVerified != phone.Verified {
|
if phone.Verified && wm.IsPhoneVerified != phone.Verified {
|
||||||
return append(cmds, user.NewHumanPhoneVerifiedEvent(ctx, &wm.Aggregate().Aggregate)), code, nil
|
return append(cmds, user.NewHumanPhoneVerifiedEvent(ctx, &wm.Aggregate().Aggregate)), code, nil
|
||||||
@ -499,14 +503,19 @@ func (c *Commands) userExistsWriteModel(ctx context.Context, userID string) (wri
|
|||||||
return writeModel, nil
|
return writeModel, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Commands) userHumanWriteModel(ctx context.Context, userID string, profileWM, emailWM, phoneWM, passwordWM, avatarWM, idpLinksWM bool) (writeModel *UserV2WriteModel, err error) {
|
func (c *Commands) UserHumanWriteModel(ctx context.Context, userID string, profileWM, emailWM, phoneWM, passwordWM, avatarWM, idpLinksWM, metadataWM bool) (writeModel *UserV2WriteModel, err error) {
|
||||||
ctx, span := tracing.NewSpan(ctx)
|
ctx, span := tracing.NewSpan(ctx)
|
||||||
defer func() { span.EndWithError(err) }()
|
defer func() { span.EndWithError(err) }()
|
||||||
|
|
||||||
writeModel = NewUserHumanWriteModel(userID, "", profileWM, emailWM, phoneWM, passwordWM, avatarWM, idpLinksWM)
|
writeModel = NewUserHumanWriteModel(userID, "", profileWM, emailWM, phoneWM, passwordWM, avatarWM, idpLinksWM, metadataWM)
|
||||||
err = c.eventstore.FilterToQueryReducer(ctx, writeModel)
|
err = c.eventstore.FilterToQueryReducer(ctx, writeModel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !isUserStateExists(writeModel.UserState) {
|
||||||
|
return nil, zerrors.ThrowNotFound(nil, "COMMAND-ugjs0upun6", "Errors.User.NotFound")
|
||||||
|
}
|
||||||
|
|
||||||
return writeModel, nil
|
return writeModel, nil
|
||||||
}
|
}
|
||||||
|
@ -15,6 +15,8 @@ import (
|
|||||||
type UserV2WriteModel struct {
|
type UserV2WriteModel struct {
|
||||||
eventstore.WriteModel
|
eventstore.WriteModel
|
||||||
|
|
||||||
|
CreationDate time.Time
|
||||||
|
|
||||||
UserName string
|
UserName string
|
||||||
|
|
||||||
MachineWriteModel bool
|
MachineWriteModel bool
|
||||||
@ -73,6 +75,9 @@ type UserV2WriteModel struct {
|
|||||||
|
|
||||||
IDPLinkWriteModel bool
|
IDPLinkWriteModel bool
|
||||||
IDPLinks []*domain.UserIDPLink
|
IDPLinks []*domain.UserIDPLink
|
||||||
|
|
||||||
|
MetadataWriteModel bool
|
||||||
|
Metadata map[string][]byte
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewUserExistsWriteModel(userID, resourceOwner string) *UserV2WriteModel {
|
func NewUserExistsWriteModel(userID, resourceOwner string) *UserV2WriteModel {
|
||||||
@ -87,7 +92,7 @@ func NewUserRemoveWriteModel(userID, resourceOwner string) *UserV2WriteModel {
|
|||||||
return newUserV2WriteModel(userID, resourceOwner, WithHuman(), WithMachine(), WithState(), WithIDPLinks())
|
return newUserV2WriteModel(userID, resourceOwner, WithHuman(), WithMachine(), WithState(), WithIDPLinks())
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewUserHumanWriteModel(userID, resourceOwner string, profileWM, emailWM, phoneWM, passwordWM, avatarWM, idpLinks bool) *UserV2WriteModel {
|
func NewUserHumanWriteModel(userID, resourceOwner string, profileWM, emailWM, phoneWM, passwordWM, avatarWM, idpLinks, metadataListWM bool) *UserV2WriteModel {
|
||||||
opts := []UserV2WMOption{WithHuman(), WithState()}
|
opts := []UserV2WMOption{WithHuman(), WithState()}
|
||||||
if profileWM {
|
if profileWM {
|
||||||
opts = append(opts, WithProfile())
|
opts = append(opts, WithProfile())
|
||||||
@ -107,6 +112,9 @@ func NewUserHumanWriteModel(userID, resourceOwner string, profileWM, emailWM, ph
|
|||||||
if idpLinks {
|
if idpLinks {
|
||||||
opts = append(opts, WithIDPLinks())
|
opts = append(opts, WithIDPLinks())
|
||||||
}
|
}
|
||||||
|
if metadataListWM {
|
||||||
|
opts = append(opts, WithMetadata())
|
||||||
|
}
|
||||||
return newUserV2WriteModel(userID, resourceOwner, opts...)
|
return newUserV2WriteModel(userID, resourceOwner, opts...)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -172,6 +180,12 @@ func WithIDPLinks() UserV2WMOption {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func WithMetadata() UserV2WMOption {
|
||||||
|
return func(o *UserV2WriteModel) {
|
||||||
|
o.MetadataWriteModel = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (wm *UserV2WriteModel) Reduce() error {
|
func (wm *UserV2WriteModel) Reduce() error {
|
||||||
for _, event := range wm.Events {
|
for _, event := range wm.Events {
|
||||||
switch e := event.(type) {
|
switch e := event.(type) {
|
||||||
@ -281,6 +295,17 @@ func (wm *UserV2WriteModel) Reduce() error {
|
|||||||
wm.RemoveIDPLink(e.IDPConfigID, e.ExternalUserID)
|
wm.RemoveIDPLink(e.IDPConfigID, e.ExternalUserID)
|
||||||
case *user.UserIDPLinkCascadeRemovedEvent:
|
case *user.UserIDPLinkCascadeRemovedEvent:
|
||||||
wm.RemoveIDPLink(e.IDPConfigID, e.ExternalUserID)
|
wm.RemoveIDPLink(e.IDPConfigID, e.ExternalUserID)
|
||||||
|
case *user.MetadataSetEvent:
|
||||||
|
if wm.Metadata == nil {
|
||||||
|
wm.Metadata = make(map[string][]byte)
|
||||||
|
}
|
||||||
|
|
||||||
|
wm.Metadata[e.Key] = e.Value
|
||||||
|
case *user.MetadataRemovedEvent:
|
||||||
|
wm.Metadata[e.Key] = nil
|
||||||
|
delete(wm.Metadata, e.Key)
|
||||||
|
case *user.MetadataRemovedAllEvent:
|
||||||
|
wm.Metadata = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return wm.WriteModel.Reduce()
|
return wm.WriteModel.Reduce()
|
||||||
@ -457,6 +482,13 @@ func (wm *UserV2WriteModel) Query() *eventstore.SearchQueryBuilder {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if wm.MetadataWriteModel {
|
||||||
|
eventTypes = append(eventTypes,
|
||||||
|
user.MetadataSetType,
|
||||||
|
user.MetadataRemovedType,
|
||||||
|
user.MetadataRemovedAllType)
|
||||||
|
}
|
||||||
|
|
||||||
query := eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent).
|
query := eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent).
|
||||||
AddQuery().
|
AddQuery().
|
||||||
AggregateTypes(user.AggregateType).
|
AggregateTypes(user.AggregateType).
|
||||||
@ -482,6 +514,7 @@ func (wm *UserV2WriteModel) reduceHumanAddedEvent(e *user.HumanAddedEvent) {
|
|||||||
wm.UserState = domain.UserStateActive
|
wm.UserState = domain.UserStateActive
|
||||||
wm.PasswordEncodedHash = crypto.SecretOrEncodedHash(e.Secret, e.EncodedHash)
|
wm.PasswordEncodedHash = crypto.SecretOrEncodedHash(e.Secret, e.EncodedHash)
|
||||||
wm.PasswordChangeRequired = e.ChangeRequired
|
wm.PasswordChangeRequired = e.ChangeRequired
|
||||||
|
wm.CreationDate = e.Creation
|
||||||
}
|
}
|
||||||
|
|
||||||
func (wm *UserV2WriteModel) reduceHumanRegisteredEvent(e *user.HumanRegisteredEvent) {
|
func (wm *UserV2WriteModel) reduceHumanRegisteredEvent(e *user.HumanRegisteredEvent) {
|
||||||
|
@ -567,7 +567,7 @@ func TestCommandSide_userHumanWriteModel_profile(t *testing.T) {
|
|||||||
r := &Commands{
|
r := &Commands{
|
||||||
eventstore: tt.fields.eventstore(t),
|
eventstore: tt.fields.eventstore(t),
|
||||||
}
|
}
|
||||||
wm, err := r.userHumanWriteModel(tt.args.ctx, tt.args.userID, true, false, false, false, false, false)
|
wm, err := r.UserHumanWriteModel(tt.args.ctx, tt.args.userID, true, false, false, false, false, false, false)
|
||||||
if tt.res.err == nil {
|
if tt.res.err == nil {
|
||||||
if !assert.NoError(t, err) {
|
if !assert.NoError(t, err) {
|
||||||
t.FailNow()
|
t.FailNow()
|
||||||
@ -912,7 +912,7 @@ func TestCommandSide_userHumanWriteModel_email(t *testing.T) {
|
|||||||
r := &Commands{
|
r := &Commands{
|
||||||
eventstore: tt.fields.eventstore(t),
|
eventstore: tt.fields.eventstore(t),
|
||||||
}
|
}
|
||||||
wm, err := r.userHumanWriteModel(tt.args.ctx, tt.args.userID, false, true, false, false, false, false)
|
wm, err := r.UserHumanWriteModel(tt.args.ctx, tt.args.userID, false, true, false, false, false, false, false)
|
||||||
if tt.res.err == nil {
|
if tt.res.err == nil {
|
||||||
if !assert.NoError(t, err) {
|
if !assert.NoError(t, err) {
|
||||||
t.FailNow()
|
t.FailNow()
|
||||||
@ -1344,7 +1344,7 @@ func TestCommandSide_userHumanWriteModel_phone(t *testing.T) {
|
|||||||
r := &Commands{
|
r := &Commands{
|
||||||
eventstore: tt.fields.eventstore(t),
|
eventstore: tt.fields.eventstore(t),
|
||||||
}
|
}
|
||||||
wm, err := r.userHumanWriteModel(tt.args.ctx, tt.args.userID, false, false, true, false, false, false)
|
wm, err := r.UserHumanWriteModel(tt.args.ctx, tt.args.userID, false, false, true, false, false, false, false)
|
||||||
if tt.res.err == nil {
|
if tt.res.err == nil {
|
||||||
if !assert.NoError(t, err) {
|
if !assert.NoError(t, err) {
|
||||||
t.FailNow()
|
t.FailNow()
|
||||||
@ -1605,7 +1605,7 @@ func TestCommandSide_userHumanWriteModel_password(t *testing.T) {
|
|||||||
r := &Commands{
|
r := &Commands{
|
||||||
eventstore: tt.fields.eventstore(t),
|
eventstore: tt.fields.eventstore(t),
|
||||||
}
|
}
|
||||||
wm, err := r.userHumanWriteModel(tt.args.ctx, tt.args.userID, false, false, false, true, false, false)
|
wm, err := r.UserHumanWriteModel(tt.args.ctx, tt.args.userID, false, false, false, true, false, false, false)
|
||||||
if tt.res.err == nil {
|
if tt.res.err == nil {
|
||||||
if !assert.NoError(t, err) {
|
if !assert.NoError(t, err) {
|
||||||
t.FailNow()
|
t.FailNow()
|
||||||
@ -2132,7 +2132,7 @@ func TestCommandSide_userHumanWriteModel_avatar(t *testing.T) {
|
|||||||
r := &Commands{
|
r := &Commands{
|
||||||
eventstore: tt.fields.eventstore(t),
|
eventstore: tt.fields.eventstore(t),
|
||||||
}
|
}
|
||||||
wm, err := r.userHumanWriteModel(tt.args.ctx, tt.args.userID, false, false, false, false, true, false)
|
wm, err := r.UserHumanWriteModel(tt.args.ctx, tt.args.userID, false, false, false, false, true, false, false)
|
||||||
if tt.res.err == nil {
|
if tt.res.err == nil {
|
||||||
if !assert.NoError(t, err) {
|
if !assert.NoError(t, err) {
|
||||||
t.FailNow()
|
t.FailNow()
|
||||||
@ -2456,3 +2456,306 @@ func TestCommandSide_userHumanWriteModel_idpLinks(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCommandSide_userHumanWriteModel_metadata(t *testing.T) {
|
||||||
|
type fields struct {
|
||||||
|
eventstore func(t *testing.T) *eventstore.Eventstore
|
||||||
|
}
|
||||||
|
type args struct {
|
||||||
|
ctx context.Context
|
||||||
|
userID string
|
||||||
|
}
|
||||||
|
type res struct {
|
||||||
|
want *UserV2WriteModel
|
||||||
|
err func(error) bool
|
||||||
|
}
|
||||||
|
|
||||||
|
userAgg := user.NewAggregate("user1", "org1")
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
fields fields
|
||||||
|
args args
|
||||||
|
res res
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "user added with metadata",
|
||||||
|
fields: fields{
|
||||||
|
eventstore: expectEventstore(
|
||||||
|
expectFilter(
|
||||||
|
eventFromEventPusher(
|
||||||
|
newAddHumanEvent("$plain$x$password", true, true, "", language.English),
|
||||||
|
),
|
||||||
|
eventFromEventPusher(
|
||||||
|
user.NewMetadataSetEvent(
|
||||||
|
context.Background(),
|
||||||
|
&userAgg.Aggregate,
|
||||||
|
"key",
|
||||||
|
[]byte("value"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
args: args{
|
||||||
|
ctx: context.Background(),
|
||||||
|
userID: "user1",
|
||||||
|
},
|
||||||
|
res: res{
|
||||||
|
want: &UserV2WriteModel{
|
||||||
|
HumanWriteModel: true,
|
||||||
|
StateWriteModel: true,
|
||||||
|
MetadataWriteModel: true,
|
||||||
|
WriteModel: eventstore.WriteModel{
|
||||||
|
AggregateID: "user1",
|
||||||
|
Events: []eventstore.Event{},
|
||||||
|
ProcessedSequence: 0,
|
||||||
|
ResourceOwner: "org1",
|
||||||
|
},
|
||||||
|
UserName: "username",
|
||||||
|
FirstName: "firstname",
|
||||||
|
LastName: "lastname",
|
||||||
|
DisplayName: "firstname lastname",
|
||||||
|
PreferredLanguage: language.English,
|
||||||
|
PasswordEncodedHash: "$plain$x$password",
|
||||||
|
PasswordChangeRequired: true,
|
||||||
|
Email: "email@test.ch",
|
||||||
|
IsEmailVerified: false,
|
||||||
|
UserState: domain.UserStateActive,
|
||||||
|
Metadata: map[string][]byte{
|
||||||
|
"key": []byte("value"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "user added with multiple metadata",
|
||||||
|
fields: fields{
|
||||||
|
eventstore: expectEventstore(
|
||||||
|
expectFilter(
|
||||||
|
eventFromEventPusher(
|
||||||
|
newAddHumanEvent("$plain$x$password", true, true, "", language.English),
|
||||||
|
),
|
||||||
|
eventFromEventPusher(
|
||||||
|
user.NewMetadataSetEvent(
|
||||||
|
context.Background(),
|
||||||
|
&userAgg.Aggregate,
|
||||||
|
"key1",
|
||||||
|
[]byte("value1"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
eventFromEventPusher(
|
||||||
|
user.NewMetadataSetEvent(
|
||||||
|
context.Background(),
|
||||||
|
&userAgg.Aggregate,
|
||||||
|
"key2",
|
||||||
|
[]byte("value2"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
eventFromEventPusher(
|
||||||
|
user.NewMetadataSetEvent(
|
||||||
|
context.Background(),
|
||||||
|
&userAgg.Aggregate,
|
||||||
|
"key3",
|
||||||
|
[]byte("value3"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
args: args{
|
||||||
|
ctx: context.Background(),
|
||||||
|
userID: "user1",
|
||||||
|
},
|
||||||
|
res: res{
|
||||||
|
want: &UserV2WriteModel{
|
||||||
|
HumanWriteModel: true,
|
||||||
|
StateWriteModel: true,
|
||||||
|
MetadataWriteModel: true,
|
||||||
|
WriteModel: eventstore.WriteModel{
|
||||||
|
AggregateID: "user1",
|
||||||
|
Events: []eventstore.Event{},
|
||||||
|
ProcessedSequence: 0,
|
||||||
|
ResourceOwner: "org1",
|
||||||
|
},
|
||||||
|
UserName: "username",
|
||||||
|
FirstName: "firstname",
|
||||||
|
LastName: "lastname",
|
||||||
|
DisplayName: "firstname lastname",
|
||||||
|
PreferredLanguage: language.English,
|
||||||
|
PasswordEncodedHash: "$plain$x$password",
|
||||||
|
PasswordChangeRequired: true,
|
||||||
|
Email: "email@test.ch",
|
||||||
|
IsEmailVerified: false,
|
||||||
|
UserState: domain.UserStateActive,
|
||||||
|
Metadata: map[string][]byte{
|
||||||
|
"key1": []byte("value1"),
|
||||||
|
"key2": []byte("value2"),
|
||||||
|
"key3": []byte("value3"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "user added with metadata add and remove",
|
||||||
|
fields: fields{
|
||||||
|
eventstore: expectEventstore(
|
||||||
|
expectFilter(
|
||||||
|
eventFromEventPusher(
|
||||||
|
newAddHumanEvent("$plain$x$password", true, true, "", language.English),
|
||||||
|
),
|
||||||
|
eventFromEventPusher(
|
||||||
|
user.NewMetadataSetEvent(
|
||||||
|
context.Background(),
|
||||||
|
&userAgg.Aggregate,
|
||||||
|
"key1",
|
||||||
|
[]byte("value1"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
eventFromEventPusher(
|
||||||
|
user.NewMetadataSetEvent(
|
||||||
|
context.Background(),
|
||||||
|
&userAgg.Aggregate,
|
||||||
|
"key2",
|
||||||
|
[]byte("name2"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
eventFromEventPusher(
|
||||||
|
user.NewMetadataSetEvent(
|
||||||
|
context.Background(),
|
||||||
|
&userAgg.Aggregate,
|
||||||
|
"key3",
|
||||||
|
[]byte("value3"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
eventFromEventPusher(
|
||||||
|
user.NewMetadataRemovedEvent(
|
||||||
|
context.Background(),
|
||||||
|
&userAgg.Aggregate,
|
||||||
|
"key2",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
eventFromEventPusher(
|
||||||
|
user.NewMetadataRemovedEvent(
|
||||||
|
context.Background(),
|
||||||
|
&userAgg.Aggregate,
|
||||||
|
"key3",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
args: args{
|
||||||
|
ctx: context.Background(),
|
||||||
|
userID: "user1",
|
||||||
|
},
|
||||||
|
res: res{
|
||||||
|
want: &UserV2WriteModel{
|
||||||
|
HumanWriteModel: true,
|
||||||
|
StateWriteModel: true,
|
||||||
|
MetadataWriteModel: true,
|
||||||
|
WriteModel: eventstore.WriteModel{
|
||||||
|
AggregateID: "user1",
|
||||||
|
Events: []eventstore.Event{},
|
||||||
|
ProcessedSequence: 0,
|
||||||
|
ResourceOwner: "org1",
|
||||||
|
},
|
||||||
|
UserName: "username",
|
||||||
|
FirstName: "firstname",
|
||||||
|
LastName: "lastname",
|
||||||
|
DisplayName: "firstname lastname",
|
||||||
|
PreferredLanguage: language.English,
|
||||||
|
PasswordEncodedHash: "$plain$x$password",
|
||||||
|
PasswordChangeRequired: true,
|
||||||
|
Email: "email@test.ch",
|
||||||
|
IsEmailVerified: false,
|
||||||
|
UserState: domain.UserStateActive,
|
||||||
|
Metadata: map[string][]byte{
|
||||||
|
"key1": []byte("value1"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "user added with added metadata and removed all",
|
||||||
|
fields: fields{
|
||||||
|
eventstore: expectEventstore(
|
||||||
|
expectFilter(
|
||||||
|
eventFromEventPusher(
|
||||||
|
newAddHumanEvent("$plain$x$password", true, true, "", language.English),
|
||||||
|
),
|
||||||
|
eventFromEventPusher(
|
||||||
|
user.NewMetadataSetEvent(
|
||||||
|
context.Background(),
|
||||||
|
&userAgg.Aggregate,
|
||||||
|
"key1",
|
||||||
|
[]byte("value1"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
eventFromEventPusher(
|
||||||
|
user.NewMetadataSetEvent(
|
||||||
|
context.Background(),
|
||||||
|
&userAgg.Aggregate,
|
||||||
|
"key2",
|
||||||
|
[]byte("value2"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
eventFromEventPusher(
|
||||||
|
user.NewMetadataRemovedAllEvent(
|
||||||
|
context.Background(),
|
||||||
|
&userAgg.Aggregate,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
args: args{
|
||||||
|
ctx: context.Background(),
|
||||||
|
userID: "user1",
|
||||||
|
},
|
||||||
|
res: res{
|
||||||
|
want: &UserV2WriteModel{
|
||||||
|
HumanWriteModel: true,
|
||||||
|
StateWriteModel: true,
|
||||||
|
MetadataWriteModel: true,
|
||||||
|
WriteModel: eventstore.WriteModel{
|
||||||
|
AggregateID: "user1",
|
||||||
|
Events: []eventstore.Event{},
|
||||||
|
ProcessedSequence: 0,
|
||||||
|
ResourceOwner: "org1",
|
||||||
|
},
|
||||||
|
UserName: "username",
|
||||||
|
FirstName: "firstname",
|
||||||
|
LastName: "lastname",
|
||||||
|
DisplayName: "firstname lastname",
|
||||||
|
PreferredLanguage: language.English,
|
||||||
|
PasswordEncodedHash: "$plain$x$password",
|
||||||
|
PasswordChangeRequired: true,
|
||||||
|
Email: "email@test.ch",
|
||||||
|
IsEmailVerified: false,
|
||||||
|
UserState: domain.UserStateActive,
|
||||||
|
Metadata: nil,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
r := &Commands{
|
||||||
|
eventstore: tt.fields.eventstore(t),
|
||||||
|
}
|
||||||
|
wm, err := r.UserHumanWriteModel(tt.args.ctx, tt.args.userID, false, false, false, false, false, false, true)
|
||||||
|
if tt.res.err == nil {
|
||||||
|
if !assert.NoError(t, err) {
|
||||||
|
t.FailNow()
|
||||||
|
}
|
||||||
|
} else if !tt.res.err(err) {
|
||||||
|
t.Errorf("got wrong err: %v ", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if tt.res.err == nil {
|
||||||
|
assert.Equal(t, tt.res.want, wm)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -4,7 +4,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -16,7 +15,7 @@ func RequireScimError(t require.TestingT, httpStatus int, err error) AssertedSci
|
|||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
|
|
||||||
var scimErr *ScimError
|
var scimErr *ScimError
|
||||||
assert.True(t, errors.As(err, &scimErr))
|
require.True(t, errors.As(err, &scimErr))
|
||||||
assert.Equal(t, strconv.Itoa(httpStatus), scimErr.Status)
|
require.Equal(t, strconv.Itoa(httpStatus), scimErr.Status)
|
||||||
return AssertedScimError{scimErr} // wrap it, otherwise error handling is enforced
|
return AssertedScimError{scimErr} // wrap it, otherwise error handling is enforced
|
||||||
}
|
}
|
||||||
|
@ -101,6 +101,15 @@ func (c *ResourceClient[T]) Replace(ctx context.Context, orgID, id string, body
|
|||||||
return c.doWithBody(ctx, http.MethodPut, orgID, id, bytes.NewReader(body))
|
return c.doWithBody(ctx, http.MethodPut, orgID, id, bytes.NewReader(body))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *ResourceClient[T]) Update(ctx context.Context, orgID, id string, body []byte) error {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, c.buildURL(orgID, id), bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.doReq(req, nil)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *ResourceClient[T]) List(ctx context.Context, orgID string, req *ListRequest) (*ListResponse[*T], error) {
|
func (c *ResourceClient[T]) List(ctx context.Context, orgID string, req *ListRequest) (*ListResponse[*T], error) {
|
||||||
if req.SendAsPost {
|
if req.SendAsPost {
|
||||||
listReq, err := json.Marshal(req)
|
listReq, err := json.Marshal(req)
|
||||||
@ -181,15 +190,16 @@ func (c *ResourceClient[T]) doReq(req *http.Request, responseEntity interface{})
|
|||||||
addTokenAsHeader(req)
|
addTokenAsHeader(req)
|
||||||
|
|
||||||
resp, err := c.client.Do(req)
|
resp, err := c.client.Do(req)
|
||||||
defer func() {
|
|
||||||
err := resp.Body.Close()
|
|
||||||
logging.OnError(err).Error("Failed to close response body")
|
|
||||||
}()
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
err := resp.Body.Close()
|
||||||
|
logging.OnError(err).Error("Failed to close response body")
|
||||||
|
}()
|
||||||
|
|
||||||
if (resp.StatusCode / 100) != 2 {
|
if (resp.StatusCode / 100) != 2 {
|
||||||
return readScimError(resp)
|
return readScimError(resp)
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user