mirror of
https://github.com/zitadel/zitadel.git
synced 2025-02-28 22:47:23 +00:00
feat: bulk scim v2 endpoint (#9256)
# Which Problems Are Solved * Adds support for the bulk SCIM v2 endpoint # How the Problems Are Solved * Adds support for the bulk SCIM v2 endpoint under `POST /scim/v2/{orgID}/Bulk` # Additional Context Part of #8140 Co-authored-by: Stefan Benz <46600784+stebenz@users.noreply.github.com>
This commit is contained in:
parent
accfb7525a
commit
df8bac8a28
@ -583,6 +583,9 @@ SCIM:
|
|||||||
# default values whether an email/phone is considered verified when a users email/phone is created or updated
|
# default values whether an email/phone is considered verified when a users email/phone is created or updated
|
||||||
EmailVerified: true # ZITADEL_SCIM_EMAILVERIFIED
|
EmailVerified: true # ZITADEL_SCIM_EMAILVERIFIED
|
||||||
PhoneVerified: true # ZITADEL_SCIM_PHONEVERIFIED
|
PhoneVerified: true # ZITADEL_SCIM_PHONEVERIFIED
|
||||||
|
MaxRequestBodySize: 1_000_000 # ZITADEL_SCIM_MAXREQUESTBODYSIZE
|
||||||
|
Bulk:
|
||||||
|
MaxOperationsCount: 100 # ZITADEL_SCIM_BULK_MAXOPERATIONSCOUNT
|
||||||
|
|
||||||
Login:
|
Login:
|
||||||
LanguageCookieName: zitadel.login.lang # ZITADEL_LOGIN_LANGUAGECOOKIENAME
|
LanguageCookieName: zitadel.login.lang # ZITADEL_LOGIN_LANGUAGECOOKIENAME
|
||||||
|
@ -28,4 +28,7 @@ var AuthMapping = authz.MethodMapping{
|
|||||||
"DELETE:/scim/v2/" + http.OrgIdInPathVariable + "/Users/{id}": {
|
"DELETE:/scim/v2/" + http.OrgIdInPathVariable + "/Users/{id}": {
|
||||||
Permission: domain.PermissionUserDelete,
|
Permission: domain.PermissionUserDelete,
|
||||||
},
|
},
|
||||||
|
"POST:/scim/v2/" + http.OrgIdInPathVariable + "/Bulk": {
|
||||||
|
Permission: "authenticated",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,12 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
EmailVerified bool
|
EmailVerified bool
|
||||||
PhoneVerified bool
|
PhoneVerified bool
|
||||||
|
MaxRequestBodySize int64
|
||||||
|
Bulk BulkConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
type BulkConfig struct {
|
||||||
|
MaxOperationsCount int
|
||||||
}
|
}
|
||||||
|
698
internal/api/scim/integration_test/bulk_test.go
Normal file
698
internal/api/scim/integration_test/bulk_test.go
Normal file
@ -0,0 +1,698 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package integration_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
_ "embed"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"path"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
//go:embed testdata/bulk_test_full.json
|
||||||
|
bulkFullJson []byte
|
||||||
|
|
||||||
|
//go:embed testdata/bulk_test_fail_on_errors.json
|
||||||
|
bulkFailOnErrorsJson []byte
|
||||||
|
|
||||||
|
//go:embed testdata/bulk_test_errors.json
|
||||||
|
bulkErrorsFullJson []byte
|
||||||
|
|
||||||
|
bulkTooManyOperationsJson []byte
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
bulkFullJson = removeComments(bulkFullJson)
|
||||||
|
bulkFailOnErrorsJson = removeComments(bulkFailOnErrorsJson)
|
||||||
|
bulkErrorsFullJson = removeComments(bulkErrorsFullJson)
|
||||||
|
bulkTooManyOperationsJson = test.Must(json.Marshal(buildTooManyOperationsRequest()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBulk(t *testing.T) {
|
||||||
|
iamOwnerCtx := Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner)
|
||||||
|
secondaryOrg := Instance.CreateOrganization(iamOwnerCtx, gofakeit.Name(), gofakeit.Email())
|
||||||
|
|
||||||
|
createdSecondaryOrgUser := createHumanUser(t, iamOwnerCtx, secondaryOrg.OrganizationId, 0)
|
||||||
|
bulkMinimalUpdateSecondaryOrgJson := test.Must(json.Marshal(buildMinimalUpdateRequest(createdSecondaryOrgUser.UserId)))
|
||||||
|
|
||||||
|
membershipNotFoundErr := &scim.ScimError{
|
||||||
|
Schemas: []string{
|
||||||
|
"urn:ietf:params:scim:api:messages:2.0:Error",
|
||||||
|
"urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail",
|
||||||
|
},
|
||||||
|
Detail: "membership not found",
|
||||||
|
Status: "404",
|
||||||
|
ZitadelDetail: &scim.ZitadelErrorDetail{
|
||||||
|
ID: "AUTHZ-cdgFk",
|
||||||
|
Message: "membership not found",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
type wantErr struct {
|
||||||
|
scimErrorType string
|
||||||
|
status int
|
||||||
|
zitadelErrID string
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
body []byte
|
||||||
|
ctx context.Context
|
||||||
|
orgID string
|
||||||
|
want *scim.BulkResponse
|
||||||
|
wantErr *wantErr
|
||||||
|
wantUsers map[string]*resources.ScimUser
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "not authenticated",
|
||||||
|
body: bulkFullJson,
|
||||||
|
ctx: context.Background(),
|
||||||
|
wantErr: &wantErr{
|
||||||
|
status: http.StatusUnauthorized,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no permissions",
|
||||||
|
body: bulkFullJson,
|
||||||
|
ctx: Instance.WithAuthorization(CTX, integration.UserTypeNoPermission),
|
||||||
|
want: &scim.BulkResponse{
|
||||||
|
Schemas: []schemas.ScimSchemaType{schemas.IdBulkResponse},
|
||||||
|
Operations: []*scim.BulkResponseOperation{
|
||||||
|
{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Response: membershipNotFoundErr,
|
||||||
|
Status: "404",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
BulkID: "1",
|
||||||
|
Response: membershipNotFoundErr,
|
||||||
|
Status: "404",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPatch,
|
||||||
|
Response: &scim.ScimError{
|
||||||
|
Schemas: []string{
|
||||||
|
"urn:ietf:params:scim:api:messages:2.0:Error",
|
||||||
|
"urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail",
|
||||||
|
},
|
||||||
|
Detail: "Could not resolve bulkID 1 to created ID",
|
||||||
|
Status: "400",
|
||||||
|
ScimType: "invalidValue",
|
||||||
|
ZitadelDetail: &scim.ZitadelErrorDetail{
|
||||||
|
ID: "SCIM-BLK4",
|
||||||
|
Message: "Could not resolve bulkID 1 to created ID",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Status: "400",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
BulkID: "2",
|
||||||
|
Response: membershipNotFoundErr,
|
||||||
|
Status: "404",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPut,
|
||||||
|
Response: &scim.ScimError{
|
||||||
|
Schemas: []string{
|
||||||
|
"urn:ietf:params:scim:api:messages:2.0:Error",
|
||||||
|
"urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail",
|
||||||
|
},
|
||||||
|
Detail: "Could not resolve bulkID 2 to created ID",
|
||||||
|
Status: "400",
|
||||||
|
ScimType: "invalidValue",
|
||||||
|
ZitadelDetail: &scim.ZitadelErrorDetail{
|
||||||
|
ID: "SCIM-BLK4",
|
||||||
|
Message: "Could not resolve bulkID 2 to created ID",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Status: "400",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
BulkID: "3",
|
||||||
|
Response: membershipNotFoundErr,
|
||||||
|
Status: "404",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodDelete,
|
||||||
|
Response: &scim.ScimError{
|
||||||
|
Schemas: []string{
|
||||||
|
"urn:ietf:params:scim:api:messages:2.0:Error",
|
||||||
|
"urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail",
|
||||||
|
},
|
||||||
|
Detail: "Could not resolve bulkID 3 to created ID",
|
||||||
|
Status: "400",
|
||||||
|
ScimType: "invalidValue",
|
||||||
|
ZitadelDetail: &scim.ZitadelErrorDetail{
|
||||||
|
ID: "SCIM-BLK4",
|
||||||
|
Message: "Could not resolve bulkID 3 to created ID",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Status: "400",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPatch,
|
||||||
|
Response: &scim.ScimError{
|
||||||
|
Schemas: []string{
|
||||||
|
"urn:ietf:params:scim:api:messages:2.0:Error",
|
||||||
|
"urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail",
|
||||||
|
},
|
||||||
|
Detail: "User could not be found",
|
||||||
|
Status: "404",
|
||||||
|
ZitadelDetail: &scim.ZitadelErrorDetail{
|
||||||
|
ID: "COMMAND-ugjs0upun6",
|
||||||
|
Message: "Errors.User.NotFound",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Status: "404",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPatch,
|
||||||
|
Response: &scim.ScimError{
|
||||||
|
Schemas: []string{
|
||||||
|
"urn:ietf:params:scim:api:messages:2.0:Error",
|
||||||
|
"urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail",
|
||||||
|
},
|
||||||
|
Detail: "Could not resolve bulkID 99 to created ID",
|
||||||
|
Status: "400",
|
||||||
|
ScimType: "invalidValue",
|
||||||
|
ZitadelDetail: &scim.ZitadelErrorDetail{
|
||||||
|
ID: "SCIM-BLK4",
|
||||||
|
Message: "Could not resolve bulkID 99 to created ID",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Status: "400",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "full",
|
||||||
|
body: bulkFullJson,
|
||||||
|
want: &scim.BulkResponse{
|
||||||
|
Schemas: []schemas.ScimSchemaType{schemas.IdBulkResponse},
|
||||||
|
Operations: []*scim.BulkResponseOperation{
|
||||||
|
{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Status: "201",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
BulkID: "1",
|
||||||
|
Status: "201",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPatch,
|
||||||
|
Status: "204",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
BulkID: "2",
|
||||||
|
Status: "201",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPut,
|
||||||
|
Status: "200",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
BulkID: "3",
|
||||||
|
Status: "201",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodDelete,
|
||||||
|
Status: "204",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPatch,
|
||||||
|
Response: &scim.ScimError{
|
||||||
|
Schemas: []string{
|
||||||
|
"urn:ietf:params:scim:api:messages:2.0:Error",
|
||||||
|
"urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail",
|
||||||
|
},
|
||||||
|
Detail: "User could not be found",
|
||||||
|
Status: "404",
|
||||||
|
ZitadelDetail: &scim.ZitadelErrorDetail{
|
||||||
|
ID: "COMMAND-ugjs0upun6",
|
||||||
|
Message: "Errors.User.NotFound",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Status: "404",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPatch,
|
||||||
|
Response: &scim.ScimError{
|
||||||
|
Schemas: []string{
|
||||||
|
"urn:ietf:params:scim:api:messages:2.0:Error",
|
||||||
|
"urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail",
|
||||||
|
},
|
||||||
|
Detail: "Could not resolve bulkID 99 to created ID",
|
||||||
|
Status: "400",
|
||||||
|
ScimType: "invalidValue",
|
||||||
|
ZitadelDetail: &scim.ZitadelErrorDetail{
|
||||||
|
ID: "SCIM-BLK4",
|
||||||
|
Message: "Could not resolve bulkID 99 to created ID",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Status: "400",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantUsers: map[string]*resources.ScimUser{
|
||||||
|
"scim-bulk-created-user-0": {
|
||||||
|
ExternalID: "scim-bulk-created-user-0",
|
||||||
|
UserName: "scim-bulk-created-user-0",
|
||||||
|
Name: &resources.ScimUserName{
|
||||||
|
Formatted: "scim-bulk-created-user-0-given-name scim-bulk-created-user-0-family-name",
|
||||||
|
FamilyName: "scim-bulk-created-user-0-family-name",
|
||||||
|
GivenName: "scim-bulk-created-user-0-given-name",
|
||||||
|
},
|
||||||
|
DisplayName: "scim-bulk-created-user-0-given-name scim-bulk-created-user-0-family-name",
|
||||||
|
PreferredLanguage: test.Must(language.Parse("en")),
|
||||||
|
Active: gu.Ptr(true),
|
||||||
|
Emails: []*resources.ScimEmail{
|
||||||
|
{
|
||||||
|
Value: "scim-bulk-created-user-0@example.com",
|
||||||
|
Primary: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"scim-bulk-created-user-1": {
|
||||||
|
ExternalID: "scim-bulk-created-user-1",
|
||||||
|
UserName: "scim-bulk-created-user-1",
|
||||||
|
Name: &resources.ScimUserName{
|
||||||
|
Formatted: "scim-bulk-created-user-1-given-name scim-bulk-created-user-1-family-name",
|
||||||
|
FamilyName: "scim-bulk-created-user-1-family-name",
|
||||||
|
GivenName: "scim-bulk-created-user-1-given-name",
|
||||||
|
},
|
||||||
|
DisplayName: "scim-bulk-created-user-1-given-name scim-bulk-created-user-1-family-name",
|
||||||
|
NickName: "scim-bulk-created-user-1-nickname-patched",
|
||||||
|
PreferredLanguage: test.Must(language.Parse("en")),
|
||||||
|
Active: gu.Ptr(true),
|
||||||
|
Emails: []*resources.ScimEmail{
|
||||||
|
{
|
||||||
|
Value: "scim-bulk-created-user-1@example.com",
|
||||||
|
Primary: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
PhoneNumbers: []*resources.ScimPhoneNumber{
|
||||||
|
{
|
||||||
|
Value: "+41711231212",
|
||||||
|
Primary: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"scim-bulk-created-user-2": {
|
||||||
|
ExternalID: "scim-bulk-created-user-2",
|
||||||
|
UserName: "scim-bulk-created-user-2",
|
||||||
|
Name: &resources.ScimUserName{
|
||||||
|
Formatted: "scim-bulk-created-user-2-given-name scim-bulk-created-user-2-family-name",
|
||||||
|
FamilyName: "scim-bulk-created-user-2-family-name",
|
||||||
|
GivenName: "scim-bulk-created-user-2-given-name",
|
||||||
|
},
|
||||||
|
DisplayName: "scim-bulk-created-user-2-given-name scim-bulk-created-user-2-family-name",
|
||||||
|
NickName: "scim-bulk-created-user-2-nickname-patched",
|
||||||
|
PreferredLanguage: test.Must(language.Parse("en")),
|
||||||
|
Active: gu.Ptr(true),
|
||||||
|
Emails: []*resources.ScimEmail{
|
||||||
|
{
|
||||||
|
Value: "scim-bulk-created-user-2@example.com",
|
||||||
|
Primary: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
PhoneNumbers: []*resources.ScimPhoneNumber{
|
||||||
|
{
|
||||||
|
Value: "+41711231212",
|
||||||
|
Primary: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "errors",
|
||||||
|
body: bulkErrorsFullJson,
|
||||||
|
want: &scim.BulkResponse{
|
||||||
|
Schemas: []schemas.ScimSchemaType{schemas.IdBulkResponse},
|
||||||
|
Operations: []*scim.BulkResponseOperation{
|
||||||
|
{
|
||||||
|
Method: http.MethodPatch,
|
||||||
|
Response: &scim.ScimError{
|
||||||
|
Schemas: []string{
|
||||||
|
"urn:ietf:params:scim:api:messages:2.0:Error",
|
||||||
|
"urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail",
|
||||||
|
},
|
||||||
|
Detail: "User could not be found",
|
||||||
|
Status: "404",
|
||||||
|
ZitadelDetail: &scim.ZitadelErrorDetail{
|
||||||
|
ID: "COMMAND-ugjs0upun6",
|
||||||
|
Message: "Errors.User.NotFound",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Status: "404",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Response: &scim.ScimError{
|
||||||
|
Schemas: []string{
|
||||||
|
"urn:ietf:params:scim:api:messages:2.0:Error",
|
||||||
|
"urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail",
|
||||||
|
},
|
||||||
|
ScimType: "invalidValue",
|
||||||
|
Detail: "Email is empty",
|
||||||
|
Status: "400",
|
||||||
|
ZitadelDetail: &scim.ZitadelErrorDetail{
|
||||||
|
ID: "SCIM-EM19",
|
||||||
|
Message: "Errors.User.Email.Empty",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Status: "400",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Response: &scim.ScimError{
|
||||||
|
Schemas: []string{
|
||||||
|
"urn:ietf:params:scim:api:messages:2.0:Error",
|
||||||
|
"urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail",
|
||||||
|
},
|
||||||
|
ScimType: "invalidValue",
|
||||||
|
Detail: "Could not parse locale",
|
||||||
|
Status: "400",
|
||||||
|
ZitadelDetail: &scim.ZitadelErrorDetail{
|
||||||
|
ID: "SCIM-MD11",
|
||||||
|
Message: "Could not parse locale",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Status: "400",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Response: &scim.ScimError{
|
||||||
|
Schemas: []string{
|
||||||
|
"urn:ietf:params:scim:api:messages:2.0:Error",
|
||||||
|
"urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail",
|
||||||
|
},
|
||||||
|
ScimType: "invalidValue",
|
||||||
|
Detail: "Password is too short",
|
||||||
|
Status: "400",
|
||||||
|
ZitadelDetail: &scim.ZitadelErrorDetail{
|
||||||
|
ID: "COMMA-HuJf6",
|
||||||
|
Message: "Errors.User.PasswordComplexityPolicy.MinLength",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Status: "400",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: "POST",
|
||||||
|
Response: &scim.ScimError{
|
||||||
|
Schemas: []string{
|
||||||
|
"urn:ietf:params:scim:api:messages:2.0:Error",
|
||||||
|
"urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail",
|
||||||
|
},
|
||||||
|
ScimType: "invalidValue",
|
||||||
|
Detail: "Could not parse timezone",
|
||||||
|
Status: "400",
|
||||||
|
ZitadelDetail: &scim.ZitadelErrorDetail{
|
||||||
|
ID: "SCIM-MD12",
|
||||||
|
Message: "Could not parse timezone",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Status: "400",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Response: &scim.ScimError{
|
||||||
|
Schemas: []string{
|
||||||
|
"urn:ietf:params:scim:api:messages:2.0:Error",
|
||||||
|
"urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail",
|
||||||
|
},
|
||||||
|
ScimType: "invalidValue",
|
||||||
|
Detail: "Errors.Invalid.Argument",
|
||||||
|
Status: "400",
|
||||||
|
ZitadelDetail: &scim.ZitadelErrorDetail{
|
||||||
|
ID: "V2-zzad3",
|
||||||
|
Message: "Errors.Invalid.Argument",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Status: "400",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: "POST",
|
||||||
|
Response: &scim.ScimError{
|
||||||
|
Schemas: []string{
|
||||||
|
"urn:ietf:params:scim:api:messages:2.0:Error",
|
||||||
|
"urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail",
|
||||||
|
},
|
||||||
|
ScimType: "invalidValue",
|
||||||
|
Detail: "Given name in profile is empty",
|
||||||
|
Status: "400",
|
||||||
|
ZitadelDetail: &scim.ZitadelErrorDetail{
|
||||||
|
ID: "USER-UCej2",
|
||||||
|
Message: "Errors.User.Profile.FirstNameEmpty",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Status: "400",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fail on errors",
|
||||||
|
body: bulkFailOnErrorsJson,
|
||||||
|
want: &scim.BulkResponse{
|
||||||
|
Schemas: []schemas.ScimSchemaType{schemas.IdBulkResponse},
|
||||||
|
Operations: []*scim.BulkResponseOperation{
|
||||||
|
{
|
||||||
|
Method: http.MethodPatch,
|
||||||
|
Response: &scim.ScimError{
|
||||||
|
Schemas: []string{
|
||||||
|
"urn:ietf:params:scim:api:messages:2.0:Error",
|
||||||
|
"urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail",
|
||||||
|
},
|
||||||
|
Detail: "User could not be found",
|
||||||
|
Status: "404",
|
||||||
|
ZitadelDetail: &scim.ZitadelErrorDetail{
|
||||||
|
ID: "COMMAND-ugjs0upun6",
|
||||||
|
Message: "Errors.User.NotFound",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Status: "404",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Status: "201",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Response: &scim.ScimError{
|
||||||
|
Schemas: []string{
|
||||||
|
"urn:ietf:params:scim:api:messages:2.0:Error",
|
||||||
|
"urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail",
|
||||||
|
},
|
||||||
|
ScimType: "invalidValue",
|
||||||
|
Detail: "Email is empty",
|
||||||
|
Status: "400",
|
||||||
|
ZitadelDetail: &scim.ZitadelErrorDetail{
|
||||||
|
ID: "SCIM-EM19",
|
||||||
|
Message: "Errors.User.Email.Empty",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Status: "400",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Response: &scim.ScimError{
|
||||||
|
Schemas: []string{
|
||||||
|
"urn:ietf:params:scim:api:messages:2.0:Error",
|
||||||
|
"urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail",
|
||||||
|
},
|
||||||
|
ScimType: "invalidValue",
|
||||||
|
Detail: "Could not parse locale",
|
||||||
|
Status: "400",
|
||||||
|
ZitadelDetail: &scim.ZitadelErrorDetail{
|
||||||
|
ID: "SCIM-MD11",
|
||||||
|
Message: "Could not parse locale",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Status: "400",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "too many operations",
|
||||||
|
body: bulkTooManyOperationsJson,
|
||||||
|
wantErr: &wantErr{
|
||||||
|
status: http.StatusRequestEntityTooLarge,
|
||||||
|
scimErrorType: "invalidValue",
|
||||||
|
zitadelErrID: "SCIM-BLK19",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "another organization",
|
||||||
|
body: bulkMinimalUpdateSecondaryOrgJson,
|
||||||
|
orgID: secondaryOrg.OrganizationId,
|
||||||
|
want: &scim.BulkResponse{
|
||||||
|
Schemas: []schemas.ScimSchemaType{schemas.IdBulkResponse},
|
||||||
|
Operations: []*scim.BulkResponseOperation{
|
||||||
|
{
|
||||||
|
Method: http.MethodPatch,
|
||||||
|
Response: membershipNotFoundErr,
|
||||||
|
Status: "404",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
ctx := tt.ctx
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = CTX
|
||||||
|
}
|
||||||
|
|
||||||
|
orgID := tt.orgID
|
||||||
|
if orgID == "" {
|
||||||
|
orgID = Instance.DefaultOrg.Id
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := Instance.Client.SCIM.Bulk(ctx, orgID, tt.body)
|
||||||
|
createdUserIDs := buildCreatedIDs(response)
|
||||||
|
defer deleteUsers(t, createdUserIDs)
|
||||||
|
|
||||||
|
if tt.wantErr != nil {
|
||||||
|
statusCode := tt.wantErr.status
|
||||||
|
if statusCode == 0 {
|
||||||
|
statusCode = http.StatusBadRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
scimErr := scim.RequireScimError(t, statusCode, err)
|
||||||
|
assert.Equal(t, tt.wantErr.scimErrorType, scimErr.Error.ScimType)
|
||||||
|
|
||||||
|
if tt.wantErr.zitadelErrID != "" {
|
||||||
|
assert.Equal(t, tt.wantErr.zitadelErrID, scimErr.Error.ZitadelDetail.ID)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.EqualValues(t, []schemas.ScimSchemaType{schemas.IdBulkResponse}, response.Schemas)
|
||||||
|
|
||||||
|
locationPrefix := "http://" + Instance.Host() + path.Join(schemas.HandlerPrefix, orgID, "Users") + "/"
|
||||||
|
for _, responseOperation := range response.Operations {
|
||||||
|
// POST operations which result in an error don't expect a location
|
||||||
|
if responseOperation.Method == http.MethodPost && responseOperation.Response != nil {
|
||||||
|
require.Empty(t, responseOperation.Location)
|
||||||
|
} else {
|
||||||
|
require.True(t, strings.HasPrefix(responseOperation.Location, locationPrefix))
|
||||||
|
}
|
||||||
|
|
||||||
|
// don't assert the location in the deep equal
|
||||||
|
responseOperation.Location = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if !reflect.DeepEqual(tt.want, response) {
|
||||||
|
x := test.Must(json.Marshal(tt.want))
|
||||||
|
x2 := test.Must(json.Marshal(response))
|
||||||
|
t.Errorf("want: %v, got: %v", x, x2)
|
||||||
|
t.Errorf("want: %#v, got: %#v", tt.want, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tt.wantUsers != nil {
|
||||||
|
for _, createdUserID := range createdUserIDs {
|
||||||
|
retryDuration, tick := integration.WaitForAndTickWithMaxDuration(ctx, time.Minute)
|
||||||
|
require.EventuallyWithT(t, func(ttt *assert.CollectT) {
|
||||||
|
user, err := Instance.Client.SCIM.Users.Get(ctx, orgID, createdUserID)
|
||||||
|
if err != nil {
|
||||||
|
scim.RequireScimError(ttt, http.StatusNotFound, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wantUser, ok := tt.wantUsers[user.UserName]
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !test.PartiallyDeepEqual(wantUser, user) {
|
||||||
|
ttt.Errorf("want: %#v, got: %#v", wantUser, user)
|
||||||
|
}
|
||||||
|
}, retryDuration, tick)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildCreatedIDs(response *scim.BulkResponse) []string {
|
||||||
|
createdIds := make([]string, 0, len(response.Operations))
|
||||||
|
for _, operation := range response.Operations {
|
||||||
|
if operation.Method == http.MethodPost && operation.Status == "201" {
|
||||||
|
parts := strings.Split(operation.Location, "/")
|
||||||
|
createdIds = append(createdIds, parts[len(parts)-1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return createdIds
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteUsers(t require.TestingT, ids []string) {
|
||||||
|
for _, id := range ids {
|
||||||
|
err := Instance.Client.SCIM.Users.Delete(CTX, Instance.DefaultOrg.Id, id)
|
||||||
|
|
||||||
|
// only not found errors are ok (if the user is deleted in a later on bulk request)
|
||||||
|
if err != nil {
|
||||||
|
scim.RequireScimError(t, http.StatusNotFound, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildMinimalUpdateRequest(userID string) *scim.BulkRequest {
|
||||||
|
return &scim.BulkRequest{
|
||||||
|
Schemas: []schemas.ScimSchemaType{schemas.IdBulkRequest},
|
||||||
|
Operations: []*scim.BulkRequestOperation{
|
||||||
|
{
|
||||||
|
Method: http.MethodPatch,
|
||||||
|
Path: "/Users/" + userID,
|
||||||
|
Data: simpleReplacePatchBody("nickname", `"foo-bar-nickname"`),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildTooManyOperationsRequest() *scim.BulkRequest {
|
||||||
|
req := &scim.BulkRequest{
|
||||||
|
Schemas: []schemas.ScimSchemaType{schemas.IdBulkRequest},
|
||||||
|
Operations: make([]*scim.BulkRequestOperation, 101), // default config (100) + 1, see defaults.yaml
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < len(req.Operations); i++ {
|
||||||
|
req.Operations[i] = &scim.BulkRequestOperation{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/Users",
|
||||||
|
Data: minimalUserJson,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return req
|
||||||
|
}
|
@ -5,6 +5,7 @@ package integration_test
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"os"
|
"os"
|
||||||
|
"regexp"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -14,6 +15,10 @@ import (
|
|||||||
var (
|
var (
|
||||||
Instance *integration.Instance
|
Instance *integration.Instance
|
||||||
CTX context.Context
|
CTX context.Context
|
||||||
|
|
||||||
|
// remove comments in the json, as the default golang json unmarshaler cannot handle them
|
||||||
|
// some test files (e.g. bulk, patch) are much easier to maintain with comments
|
||||||
|
removeCommentsRegex = regexp.MustCompile("(?s)//.*?\n|/\\*.*?\\*/")
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMain(m *testing.M) {
|
func TestMain(m *testing.M) {
|
||||||
@ -27,3 +32,7 @@ func TestMain(m *testing.M) {
|
|||||||
return m.Run()
|
return m.Run()
|
||||||
}())
|
}())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func removeComments(json []byte) []byte {
|
||||||
|
return removeCommentsRegex.ReplaceAll(json, nil)
|
||||||
|
}
|
||||||
|
145
internal/api/scim/integration_test/testdata/bulk_test_errors.json
vendored
Normal file
145
internal/api/scim/integration_test/testdata/bulk_test_errors.json
vendored
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
{
|
||||||
|
"schemas": [
|
||||||
|
"urn:ietf:params:scim:api:messages:2.0:BulkRequest"
|
||||||
|
],
|
||||||
|
"Operations": [
|
||||||
|
// update unknown user
|
||||||
|
{
|
||||||
|
"method": "PATCH",
|
||||||
|
"path": "/Users/fooBar",
|
||||||
|
"data": {
|
||||||
|
"schemas": [
|
||||||
|
"urn:ietf:params:scim:api:messages:2.0:PatchOp"
|
||||||
|
],
|
||||||
|
"Operations": [
|
||||||
|
{
|
||||||
|
"op": "replace",
|
||||||
|
"path": "nickname",
|
||||||
|
"value": "scim-bulk-created-user-1-nickname"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// create user without email
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/Users",
|
||||||
|
"data": {
|
||||||
|
"schemas": [
|
||||||
|
"urn:ietf:params:scim:schemas:core:2.0:User"
|
||||||
|
],
|
||||||
|
"userName": "acmeUser1",
|
||||||
|
"name": {
|
||||||
|
"familyName": "Ross",
|
||||||
|
"givenName": "Bethany"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// create user with invalid locale
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/Users",
|
||||||
|
"data": {
|
||||||
|
"schemas": [
|
||||||
|
"urn:ietf:params:scim:schemas:core:2.0:User"
|
||||||
|
],
|
||||||
|
"userName": "acmeUser1",
|
||||||
|
"name": {
|
||||||
|
"familyName": "Ross",
|
||||||
|
"givenName": "Bethany"
|
||||||
|
},
|
||||||
|
"emails": [
|
||||||
|
{
|
||||||
|
"value": "user1@example.com",
|
||||||
|
"primary": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"locale": "fooBar"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// create user with invalid password
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/Users",
|
||||||
|
"data": {
|
||||||
|
"schemas": [
|
||||||
|
"urn:ietf:params:scim:schemas:core:2.0:User"
|
||||||
|
],
|
||||||
|
"userName": "acmeUser1",
|
||||||
|
"name": {
|
||||||
|
"familyName": "Ross",
|
||||||
|
"givenName": "Bethany"
|
||||||
|
},
|
||||||
|
"emails": [
|
||||||
|
{
|
||||||
|
"value": "user1@example.com",
|
||||||
|
"primary": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"password": "fooBar"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// create user with invalid timezone
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/Users",
|
||||||
|
"data": {
|
||||||
|
"schemas": [
|
||||||
|
"urn:ietf:params:scim:schemas:core:2.0:User"
|
||||||
|
],
|
||||||
|
"userName": "acmeUser1",
|
||||||
|
"name": {
|
||||||
|
"familyName": "Ross",
|
||||||
|
"givenName": "Bethany"
|
||||||
|
},
|
||||||
|
"emails": [
|
||||||
|
{
|
||||||
|
"value": "user1@example.com",
|
||||||
|
"primary": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"timezone": "fooBar"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// create user without username
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/Users",
|
||||||
|
"data": {
|
||||||
|
"schemas": [
|
||||||
|
"urn:ietf:params:scim:schemas:core:2.0:User"
|
||||||
|
],
|
||||||
|
"externalId": "scim-bulk-created-user-0",
|
||||||
|
"name": {
|
||||||
|
"familyName": "scim-bulk-created-user-0-family-name",
|
||||||
|
"givenName": "scim-bulk-created-user-0-given-name"
|
||||||
|
},
|
||||||
|
"emails": [
|
||||||
|
{
|
||||||
|
"value": "scim-bulk-created-user-0@example.com",
|
||||||
|
"primary": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"password": "Password1!"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// create user without name
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/Users",
|
||||||
|
"data": {
|
||||||
|
"schemas": [
|
||||||
|
"urn:ietf:params:scim:schemas:core:2.0:User"
|
||||||
|
],
|
||||||
|
"username": "scim-bulk-created-username-0",
|
||||||
|
"emails": [
|
||||||
|
{
|
||||||
|
"value": "scim-bulk-created-user-0@example.com",
|
||||||
|
"primary": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"password": "Password1!"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
171
internal/api/scim/integration_test/testdata/bulk_test_fail_on_errors.json
vendored
Normal file
171
internal/api/scim/integration_test/testdata/bulk_test_fail_on_errors.json
vendored
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
{
|
||||||
|
"schemas": [
|
||||||
|
"urn:ietf:params:scim:api:messages:2.0:BulkRequest"
|
||||||
|
],
|
||||||
|
// same as bulk_test_errors but with failOnErrors set
|
||||||
|
// and one additional operation at index 1 without an error
|
||||||
|
"failOnErrors": 3,
|
||||||
|
"Operations": [
|
||||||
|
// update unknown user
|
||||||
|
{
|
||||||
|
"method": "PATCH",
|
||||||
|
"path": "/Users/fooBar",
|
||||||
|
"data": {
|
||||||
|
"schemas": [
|
||||||
|
"urn:ietf:params:scim:api:messages:2.0:PatchOp"
|
||||||
|
],
|
||||||
|
"Operations": [
|
||||||
|
{
|
||||||
|
"op": "replace",
|
||||||
|
"path": "nickname",
|
||||||
|
"value": "scim-bulk-created-user-1-nickname"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// create a user without error
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/Users",
|
||||||
|
"data": {
|
||||||
|
"schemas": [
|
||||||
|
"urn:ietf:params:scim:schemas:core:2.0:User"
|
||||||
|
],
|
||||||
|
"externalId": "scim-bulk-created-user-0",
|
||||||
|
"userName": "scim-bulk-created-user-0",
|
||||||
|
"name": {
|
||||||
|
"familyName": "scim-bulk-created-user-0-family-name",
|
||||||
|
"givenName": "scim-bulk-created-user-0-given-name"
|
||||||
|
},
|
||||||
|
"emails": [
|
||||||
|
{
|
||||||
|
"value": "scim-bulk-created-user-0@example.com",
|
||||||
|
"primary": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"password": "Password1!"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// create user without email
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/Users",
|
||||||
|
"data": {
|
||||||
|
"schemas": [
|
||||||
|
"urn:ietf:params:scim:schemas:core:2.0:User"
|
||||||
|
],
|
||||||
|
"userName": "acmeUser1",
|
||||||
|
"name": {
|
||||||
|
"familyName": "Ross",
|
||||||
|
"givenName": "Bethany"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// create user with invalid locale
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/Users",
|
||||||
|
"data": {
|
||||||
|
"schemas": [
|
||||||
|
"urn:ietf:params:scim:schemas:core:2.0:User"
|
||||||
|
],
|
||||||
|
"userName": "acmeUser1",
|
||||||
|
"name": {
|
||||||
|
"familyName": "Ross",
|
||||||
|
"givenName": "Bethany"
|
||||||
|
},
|
||||||
|
"emails": [
|
||||||
|
{
|
||||||
|
"value": "user1@example.com",
|
||||||
|
"primary": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"locale": "fooBar"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// create user with invalid password
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/Users",
|
||||||
|
"data": {
|
||||||
|
"schemas": [
|
||||||
|
"urn:ietf:params:scim:schemas:core:2.0:User"
|
||||||
|
],
|
||||||
|
"userName": "acmeUser1",
|
||||||
|
"name": {
|
||||||
|
"familyName": "Ross",
|
||||||
|
"givenName": "Bethany"
|
||||||
|
},
|
||||||
|
"emails": [
|
||||||
|
{
|
||||||
|
"value": "user1@example.com",
|
||||||
|
"primary": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"password": "fooBar"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// create user with invalid timezone
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/Users",
|
||||||
|
"data": {
|
||||||
|
"schemas": [
|
||||||
|
"urn:ietf:params:scim:schemas:core:2.0:User"
|
||||||
|
],
|
||||||
|
"userName": "acmeUser1",
|
||||||
|
"name": {
|
||||||
|
"familyName": "Ross",
|
||||||
|
"givenName": "Bethany"
|
||||||
|
},
|
||||||
|
"emails": [
|
||||||
|
{
|
||||||
|
"value": "user1@example.com",
|
||||||
|
"primary": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"timezone": "fooBar"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// create user without username
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/Users",
|
||||||
|
"data": {
|
||||||
|
"schemas": [
|
||||||
|
"urn:ietf:params:scim:schemas:core:2.0:User"
|
||||||
|
],
|
||||||
|
"externalId": "scim-bulk-created-user-0",
|
||||||
|
"name": {
|
||||||
|
"familyName": "scim-bulk-created-user-0-family-name",
|
||||||
|
"givenName": "scim-bulk-created-user-0-given-name"
|
||||||
|
},
|
||||||
|
"emails": [
|
||||||
|
{
|
||||||
|
"value": "scim-bulk-created-user-0@example.com",
|
||||||
|
"primary": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"password": "Password1!"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// create user without name
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/Users",
|
||||||
|
"data": {
|
||||||
|
"schemas": [
|
||||||
|
"urn:ietf:params:scim:schemas:core:2.0:User"
|
||||||
|
],
|
||||||
|
"username": "scim-bulk-created-username-0",
|
||||||
|
"emails": [
|
||||||
|
{
|
||||||
|
"value": "scim-bulk-created-user-0@example.com",
|
||||||
|
"primary": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"password": "Password1!"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
188
internal/api/scim/integration_test/testdata/bulk_test_full.json
vendored
Normal file
188
internal/api/scim/integration_test/testdata/bulk_test_full.json
vendored
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
{
|
||||||
|
"schemas": [
|
||||||
|
"urn:ietf:params:scim:api:messages:2.0:BulkRequest"
|
||||||
|
],
|
||||||
|
"Operations": [
|
||||||
|
// create a minimal user without a bulkId
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/Users",
|
||||||
|
"data": {
|
||||||
|
"schemas": [
|
||||||
|
"urn:ietf:params:scim:schemas:core:2.0:User"
|
||||||
|
],
|
||||||
|
"externalId": "scim-bulk-created-user-0",
|
||||||
|
"userName": "scim-bulk-created-user-0",
|
||||||
|
"name": {
|
||||||
|
"familyName": "scim-bulk-created-user-0-family-name",
|
||||||
|
"givenName": "scim-bulk-created-user-0-given-name"
|
||||||
|
},
|
||||||
|
"emails": [
|
||||||
|
{
|
||||||
|
"value": "scim-bulk-created-user-0@example.com",
|
||||||
|
"primary": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"password": "Password1!"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// create a new minimal user
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/Users",
|
||||||
|
"bulkId": "1",
|
||||||
|
"data": {
|
||||||
|
"schemas": [
|
||||||
|
"urn:ietf:params:scim:schemas:core:2.0:User"
|
||||||
|
],
|
||||||
|
"externalId": "scim-bulk-created-user-1",
|
||||||
|
"userName": "scim-bulk-created-user-1",
|
||||||
|
"name": {
|
||||||
|
"familyName": "scim-bulk-created-user-1-family-name",
|
||||||
|
"givenName": "scim-bulk-created-user-1-given-name"
|
||||||
|
},
|
||||||
|
"emails": [
|
||||||
|
{
|
||||||
|
"value": "scim-bulk-created-user-1@example.com",
|
||||||
|
"primary": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"password": "Password1!"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// update the just created user
|
||||||
|
{
|
||||||
|
"method": "PATCH",
|
||||||
|
"path": "/Users/bulkId:1",
|
||||||
|
"data": {
|
||||||
|
"schemas": [
|
||||||
|
"urn:ietf:params:scim:api:messages:2.0:PatchOp"
|
||||||
|
],
|
||||||
|
"Operations": [
|
||||||
|
{
|
||||||
|
"op": "replace",
|
||||||
|
"path": "nickname",
|
||||||
|
"value": "scim-bulk-created-user-1-nickname-patched"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"op": "replace",
|
||||||
|
"path": "phonenumbers",
|
||||||
|
"value": {
|
||||||
|
"value": "+41711231212",
|
||||||
|
"primary": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// create another user
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/Users",
|
||||||
|
"bulkId": "2",
|
||||||
|
"data": {
|
||||||
|
"schemas": [
|
||||||
|
"urn:ietf:params:scim:schemas:core:2.0:User"
|
||||||
|
],
|
||||||
|
"externalId": "scim-bulk-created-user-2",
|
||||||
|
"userName": "scim-bulk-created-user-2",
|
||||||
|
"name": {
|
||||||
|
"familyName": "scim-bulk-created-user-2-family-name",
|
||||||
|
"givenName": "scim-bulk-created-user-2-given-name"
|
||||||
|
},
|
||||||
|
"emails": [
|
||||||
|
{
|
||||||
|
"value": "scim-bulk-created-user-2@example.com",
|
||||||
|
"primary": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"password": "Password1!"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// replace the just created user
|
||||||
|
{
|
||||||
|
"method": "PUT",
|
||||||
|
"path": "/Users/bulkId:2",
|
||||||
|
"data": {
|
||||||
|
"schemas": [
|
||||||
|
"urn:ietf:params:scim:schemas:core:2.0:User"
|
||||||
|
],
|
||||||
|
"externalId": "scim-bulk-created-user-2-updated",
|
||||||
|
"userName": "scim-bulk-created-user-2-updated",
|
||||||
|
"name": {
|
||||||
|
"familyName": "scim-bulk-created-user-2-family-name-updated",
|
||||||
|
"givenName": "scim-bulk-created-user-2-given-name-updated"
|
||||||
|
},
|
||||||
|
"emails": [
|
||||||
|
{
|
||||||
|
"value": "scim-bulk-created-user-2-updated@example.com",
|
||||||
|
"primary": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// create another user
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/Users",
|
||||||
|
"bulkId": "3",
|
||||||
|
"data": {
|
||||||
|
"schemas": [
|
||||||
|
"urn:ietf:params:scim:schemas:core:2.0:User"
|
||||||
|
],
|
||||||
|
"externalId": "scim-bulk-created-user-3",
|
||||||
|
"userName": "scim-bulk-created-user-3",
|
||||||
|
"name": {
|
||||||
|
"familyName": "scim-bulk-created-user-3-family-name",
|
||||||
|
"givenName": "scim-bulk-created-user-3-given-name"
|
||||||
|
},
|
||||||
|
"emails": [
|
||||||
|
{
|
||||||
|
"value": "scim-bulk-created-user-3@example.com",
|
||||||
|
"primary": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"password": "Password1!"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// delete the just created user
|
||||||
|
{
|
||||||
|
"method": "DELETE",
|
||||||
|
"path": "/Users/bulkId:3"
|
||||||
|
},
|
||||||
|
// update unknown user
|
||||||
|
{
|
||||||
|
"method": "PATCH",
|
||||||
|
"path": "/Users/fooBar",
|
||||||
|
"data": {
|
||||||
|
"schemas": [
|
||||||
|
"urn:ietf:params:scim:api:messages:2.0:PatchOp"
|
||||||
|
],
|
||||||
|
"Operations": [
|
||||||
|
{
|
||||||
|
"op": "replace",
|
||||||
|
"path": "nickname",
|
||||||
|
"value": "scim-bulk-created-user-1-nickname"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// update unknown bulkId user
|
||||||
|
{
|
||||||
|
"method": "PATCH",
|
||||||
|
"path": "/Users/bulkId:99",
|
||||||
|
"data": {
|
||||||
|
"schemas": [
|
||||||
|
"urn:ietf:params:scim:api:messages:2.0:PatchOp"
|
||||||
|
],
|
||||||
|
"Operations": [
|
||||||
|
{
|
||||||
|
"op": "replace",
|
||||||
|
"path": "nickname",
|
||||||
|
"value": "scim-bulk-created-user-1-nickname"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
@ -190,6 +190,7 @@ func TestListUser(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "list paged sorted users with filter as post",
|
name: "list paged sorted users with filter as post",
|
||||||
req: &scim.ListRequest{
|
req: &scim.ListRequest{
|
||||||
|
Schemas: []schemas.ScimSchemaType{schemas.IdSearchRequest},
|
||||||
Count: gu.Ptr(5),
|
Count: gu.Ptr(5),
|
||||||
StartIndex: gu.Ptr(1),
|
StartIndex: gu.Ptr(1),
|
||||||
SortOrder: gu.Ptr(scim.ListRequestSortOrderAsc),
|
SortOrder: gu.Ptr(scim.ListRequestSortOrderAsc),
|
||||||
|
@ -7,7 +7,6 @@ import (
|
|||||||
_ "embed"
|
_ "embed"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -29,21 +28,13 @@ var (
|
|||||||
//go:embed testdata/users_update_test_full.json
|
//go:embed testdata/users_update_test_full.json
|
||||||
fullUserUpdateJson []byte
|
fullUserUpdateJson []byte
|
||||||
|
|
||||||
minimalUserUpdateJson []byte = simpleReplacePatchBody("nickname", "foo")
|
minimalUserUpdateJson = 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() {
|
func init() {
|
||||||
fullUserUpdateJson = removeComments(fullUserUpdateJson)
|
fullUserUpdateJson = removeComments(fullUserUpdateJson)
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeComments(json []byte) []byte {
|
|
||||||
return removeCommentsRegex.ReplaceAll(json, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateUser(t *testing.T) {
|
func TestUpdateUser(t *testing.T) {
|
||||||
fullUserCreated, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, fullUserJson)
|
fullUserCreated, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, fullUserJson)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
@ -2,22 +2,57 @@ package metadata
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/zitadel/zitadel/internal/zerrors"
|
||||||
)
|
)
|
||||||
|
|
||||||
type provisioningDomainKeyType struct{}
|
const bulkIDPrefix = "bulkid:"
|
||||||
|
|
||||||
var provisioningDomainKey provisioningDomainKeyType
|
type scimContextKeyType struct{}
|
||||||
|
|
||||||
|
var scimContextKey scimContextKeyType
|
||||||
|
|
||||||
type ScimContextData struct {
|
type ScimContextData struct {
|
||||||
ProvisioningDomain string
|
ProvisioningDomain string
|
||||||
ExternalIDScopedMetadataKey ScopedKey
|
ExternalIDScopedMetadataKey ScopedKey
|
||||||
|
bulkIDMapping map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewScimContextData() ScimContextData {
|
||||||
|
return ScimContextData{
|
||||||
|
ExternalIDScopedMetadataKey: ScopedKey(KeyExternalId),
|
||||||
|
bulkIDMapping: make(map[string]string),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func SetScimContextData(ctx context.Context, data ScimContextData) context.Context {
|
func SetScimContextData(ctx context.Context, data ScimContextData) context.Context {
|
||||||
return context.WithValue(ctx, provisioningDomainKey, data)
|
return context.WithValue(ctx, scimContextKey, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetScimContextData(ctx context.Context) ScimContextData {
|
func GetScimContextData(ctx context.Context) ScimContextData {
|
||||||
data, _ := ctx.Value(provisioningDomainKey).(ScimContextData)
|
data, _ := ctx.Value(scimContextKey).(ScimContextData)
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func SetScimBulkIDMapping(ctx context.Context, bulkID, zitadelID string) context.Context {
|
||||||
|
data := GetScimContextData(ctx)
|
||||||
|
data.bulkIDMapping[bulkID] = zitadelID
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolveScimBulkIDIfNeeded(ctx context.Context, resourceID string) (string, error) {
|
||||||
|
lowerResourceID := strings.ToLower(resourceID)
|
||||||
|
if !strings.HasPrefix(lowerResourceID, bulkIDPrefix) {
|
||||||
|
return resourceID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
bulkID := strings.TrimPrefix(lowerResourceID, bulkIDPrefix)
|
||||||
|
data := GetScimContextData(ctx)
|
||||||
|
zitadelID, ok := data.bulkIDMapping[bulkID]
|
||||||
|
if !ok {
|
||||||
|
return bulkID, zerrors.ThrowInvalidArgumentf(nil, "SCIM-BLK4", "Could not resolve bulkID %v to created ID", bulkID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return zitadelID, nil
|
||||||
|
}
|
||||||
|
@ -25,11 +25,7 @@ func ScimContextMiddleware(q *query.Queries) func(next zhttp.HandlerFuncWithErro
|
|||||||
}
|
}
|
||||||
|
|
||||||
func initScimContext(ctx context.Context, q *query.Queries) (context.Context, error) {
|
func initScimContext(ctx context.Context, q *query.Queries) (context.Context, error) {
|
||||||
data := smetadata.ScimContextData{
|
data := smetadata.NewScimContextData()
|
||||||
ProvisioningDomain: "",
|
|
||||||
ExternalIDScopedMetadataKey: smetadata.ScopedKey(smetadata.KeyExternalId),
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx = smetadata.SetScimContextData(ctx, data)
|
ctx = smetadata.SetScimContextData(ctx, data)
|
||||||
|
|
||||||
userID := authz.GetCtxData(ctx).UserID
|
userID := authz.GetCtxData(ctx).UserID
|
||||||
|
239
internal/api/scim/resources/bulk.go
Normal file
239
internal/api/scim/resources/bulk.go
Normal file
@ -0,0 +1,239 @@
|
|||||||
|
package resources
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/zitadel/logging"
|
||||||
|
|
||||||
|
scim_config "github.com/zitadel/zitadel/internal/api/scim/config"
|
||||||
|
"github.com/zitadel/zitadel/internal/api/scim/metadata"
|
||||||
|
"github.com/zitadel/zitadel/internal/api/scim/schemas"
|
||||||
|
"github.com/zitadel/zitadel/internal/api/scim/serrors"
|
||||||
|
"github.com/zitadel/zitadel/internal/zerrors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BulkHandler struct {
|
||||||
|
cfg *scim_config.BulkConfig
|
||||||
|
handlersByPluralResourceName map[schemas.ScimResourceTypePlural]RawResourceHandlerAdapter
|
||||||
|
}
|
||||||
|
|
||||||
|
type BulkRequest struct {
|
||||||
|
Schemas []schemas.ScimSchemaType `json:"schemas"`
|
||||||
|
FailOnErrors *int `json:"failOnErrors"`
|
||||||
|
Operations []*BulkRequestOperation `json:"Operations"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BulkRequestOperation struct {
|
||||||
|
Method string `json:"method"`
|
||||||
|
BulkID string `json:"bulkId"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Data json.RawMessage `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BulkResponse struct {
|
||||||
|
Schemas []schemas.ScimSchemaType `json:"schemas"`
|
||||||
|
Operations []*BulkResponseOperation `json:"Operations"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BulkResponseOperation struct {
|
||||||
|
Method string `json:"method"`
|
||||||
|
BulkID string `json:"bulkId,omitempty"`
|
||||||
|
Location string `json:"location,omitempty"`
|
||||||
|
Error *serrors.ScimError `json:"response,omitempty"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *BulkRequest) GetSchemas() []schemas.ScimSchemaType {
|
||||||
|
return r.Schemas
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBulkHandler(
|
||||||
|
cfg scim_config.BulkConfig,
|
||||||
|
handlers ...RawResourceHandlerAdapter,
|
||||||
|
) *BulkHandler {
|
||||||
|
handlersByPluralResourceName := make(map[schemas.ScimResourceTypePlural]RawResourceHandlerAdapter, len(handlers))
|
||||||
|
for _, handler := range handlers {
|
||||||
|
handlersByPluralResourceName[handler.ResourceNamePlural()] = handler
|
||||||
|
}
|
||||||
|
|
||||||
|
return &BulkHandler{
|
||||||
|
&cfg,
|
||||||
|
handlersByPluralResourceName,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *BulkHandler) BulkFromHttp(r *http.Request) (*BulkResponse, error) {
|
||||||
|
req, err := h.readBulkRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return h.processRequest(r.Context(), req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *BulkHandler) readBulkRequest(r *http.Request) (*BulkRequest, error) {
|
||||||
|
request := new(BulkRequest)
|
||||||
|
if err := readSchema(r.Body, request, schemas.IdBulkRequest); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(request.Operations) > h.cfg.MaxOperationsCount {
|
||||||
|
return nil, serrors.ThrowPayloadTooLarge(zerrors.ThrowInvalidArgumentf(nil, "SCIM-BLK19", "Too many bulk operations in one request, max %d allowed.", h.cfg.MaxOperationsCount))
|
||||||
|
}
|
||||||
|
return request, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *BulkHandler) processRequest(ctx context.Context, req *BulkRequest) (*BulkResponse, error) {
|
||||||
|
errorBudget := math.MaxInt32
|
||||||
|
if req.FailOnErrors != nil {
|
||||||
|
errorBudget = *req.FailOnErrors
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := &BulkResponse{
|
||||||
|
Schemas: []schemas.ScimSchemaType{schemas.IdBulkResponse},
|
||||||
|
Operations: make([]*BulkResponseOperation, 0, len(req.Operations)),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, operation := range req.Operations {
|
||||||
|
opResp := h.processOperation(ctx, operation)
|
||||||
|
resp.Operations = append(resp.Operations, opResp)
|
||||||
|
|
||||||
|
if opResp.Error == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
errorBudget--
|
||||||
|
if errorBudget <= 0 {
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *BulkHandler) processOperation(ctx context.Context, op *BulkRequestOperation) (opResp *BulkResponseOperation) {
|
||||||
|
var statusCode int
|
||||||
|
var resourceNamePlural schemas.ScimResourceTypePlural
|
||||||
|
var resourceID string
|
||||||
|
var err error
|
||||||
|
opResp = &BulkResponseOperation{
|
||||||
|
Method: op.Method,
|
||||||
|
BulkID: op.BulkID,
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
logging.WithFields("panic", r).Error("Bulk operation panic")
|
||||||
|
err = zerrors.ThrowInternal(nil, "SCIM-BLK12", "Internal error while processing bulk operation")
|
||||||
|
}
|
||||||
|
|
||||||
|
if resourceNamePlural != "" && resourceID != "" {
|
||||||
|
opResp.Location = buildLocation(ctx, resourceNamePlural, resourceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
opResp.Status = strconv.Itoa(statusCode)
|
||||||
|
if err != nil {
|
||||||
|
opResp.Error = serrors.MapToScimError(ctx, err)
|
||||||
|
opResp.Status = opResp.Error.Status
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
resourceNamePlural, resourceID, err = h.parsePath(op.Path)
|
||||||
|
if err != nil {
|
||||||
|
return opResp
|
||||||
|
}
|
||||||
|
|
||||||
|
resourceID, err = metadata.ResolveScimBulkIDIfNeeded(ctx, resourceID)
|
||||||
|
if err != nil {
|
||||||
|
return opResp
|
||||||
|
}
|
||||||
|
|
||||||
|
resourceHandler, ok := h.handlersByPluralResourceName[resourceNamePlural]
|
||||||
|
if !ok {
|
||||||
|
err = zerrors.ThrowInvalidArgumentf(nil, "SCIM-BLK13", "Unknown resource %s", resourceNamePlural)
|
||||||
|
return opResp
|
||||||
|
}
|
||||||
|
|
||||||
|
switch op.Method {
|
||||||
|
case http.MethodPatch:
|
||||||
|
statusCode = http.StatusNoContent
|
||||||
|
err = h.processPatchOperation(ctx, resourceHandler, resourceID, op.Data)
|
||||||
|
case http.MethodPut:
|
||||||
|
statusCode = http.StatusOK
|
||||||
|
err = h.processPutOperation(ctx, resourceHandler, resourceID, op)
|
||||||
|
case http.MethodPost:
|
||||||
|
statusCode = http.StatusCreated
|
||||||
|
resourceID, err = h.processPostOperation(ctx, resourceHandler, resourceID, op)
|
||||||
|
case http.MethodDelete:
|
||||||
|
statusCode = http.StatusNoContent
|
||||||
|
err = h.processDeleteOperation(ctx, resourceHandler, resourceID)
|
||||||
|
default:
|
||||||
|
err = zerrors.ThrowInvalidArgumentf(nil, "SCIM-BLK14", "Unsupported operation %s", op.Method)
|
||||||
|
}
|
||||||
|
|
||||||
|
return opResp
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *BulkHandler) processPutOperation(ctx context.Context, resourceHandler RawResourceHandlerAdapter, resourceID string, op *BulkRequestOperation) error {
|
||||||
|
data := io.NopCloser(bytes.NewReader(op.Data))
|
||||||
|
_, err := resourceHandler.Replace(ctx, resourceID, data)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *BulkHandler) processPostOperation(ctx context.Context, resourceHandler RawResourceHandlerAdapter, resourceID string, op *BulkRequestOperation) (string, error) {
|
||||||
|
if resourceID != "" {
|
||||||
|
return "", zerrors.ThrowInvalidArgumentf(nil, "SCIM-BLK56", "Cannot post with a resourceID")
|
||||||
|
}
|
||||||
|
|
||||||
|
data := io.NopCloser(bytes.NewReader(op.Data))
|
||||||
|
createdResource, err := resourceHandler.Create(ctx, data)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
id := createdResource.GetResource().ID
|
||||||
|
if op.BulkID != "" {
|
||||||
|
metadata.SetScimBulkIDMapping(ctx, op.BulkID, id)
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *BulkHandler) processPatchOperation(ctx context.Context, resourceHandler RawResourceHandlerAdapter, resourceID string, data json.RawMessage) error {
|
||||||
|
if resourceID == "" {
|
||||||
|
return zerrors.ThrowInvalidArgumentf(nil, "SCIM-BLK16", "To patch a resource, a resourceID is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
return resourceHandler.Update(ctx, resourceID, io.NopCloser(bytes.NewReader(data)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *BulkHandler) processDeleteOperation(ctx context.Context, resourceHandler RawResourceHandlerAdapter, resourceID string) error {
|
||||||
|
if resourceID == "" {
|
||||||
|
return zerrors.ThrowInvalidArgumentf(nil, "SCIM-BLK17", "To delete a resource, a resourceID is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
return resourceHandler.Delete(ctx, resourceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *BulkHandler) parsePath(path string) (schemas.ScimResourceTypePlural, string, error) {
|
||||||
|
if !strings.HasPrefix(path, "/") {
|
||||||
|
return "", "", zerrors.ThrowInvalidArgumentf(nil, "SCIM-BLK19", "Invalid path: has to start with a /")
|
||||||
|
}
|
||||||
|
|
||||||
|
// part 0 is always an empty string due to the leading /
|
||||||
|
pathParts := strings.Split(path, "/")
|
||||||
|
switch len(pathParts) {
|
||||||
|
case 2:
|
||||||
|
return schemas.ScimResourceTypePlural(pathParts[1]), "", nil
|
||||||
|
case 3:
|
||||||
|
return schemas.ScimResourceTypePlural(pathParts[1]), pathParts[2], nil
|
||||||
|
default:
|
||||||
|
return "", "", zerrors.ThrowInvalidArgumentf(nil, "SCIM-BLK20", "Invalid resource path")
|
||||||
|
}
|
||||||
|
}
|
@ -3,7 +3,6 @@ package patch
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"reflect"
|
"reflect"
|
||||||
"slices"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/zitadel/logging"
|
"github.com/zitadel/logging"
|
||||||
@ -46,11 +45,11 @@ type ResourcePatcher interface {
|
|||||||
Removed(attributePath []string) error
|
Removed(attributePath []string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (req *OperationRequest) Validate() error {
|
func (req *OperationRequest) GetSchemas() []schemas.ScimSchemaType {
|
||||||
if !slices.Contains(req.Schemas, schemas.IdPatchOperation) {
|
return req.Schemas
|
||||||
return serrors.ThrowInvalidSyntax(zerrors.ThrowInvalidArgumentf(nil, "SCIM-xy1schema", "Expected schema %v is not provided", schemas.IdPatchOperation))
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
func (req *OperationRequest) Validate() error {
|
||||||
for _, op := range req.Operations {
|
for _, op := range req.Operations {
|
||||||
if err := op.validate(); err != nil {
|
if err := op.validate(); err != nil {
|
||||||
return err
|
return err
|
||||||
|
@ -14,9 +14,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type ResourceHandler[T ResourceHolder] interface {
|
type ResourceHandler[T ResourceHolder] interface {
|
||||||
|
SchemaType() schemas.ScimSchemaType
|
||||||
ResourceNameSingular() schemas.ScimResourceTypeSingular
|
ResourceNameSingular() schemas.ScimResourceTypeSingular
|
||||||
ResourceNamePlural() schemas.ScimResourceTypePlural
|
ResourceNamePlural() schemas.ScimResourceTypePlural
|
||||||
SchemaType() schemas.ScimSchemaType
|
|
||||||
NewResource() T
|
NewResource() T
|
||||||
|
|
||||||
Create(ctx context.Context, resource T) (T, error)
|
Create(ctx context.Context, resource T) (T, error)
|
||||||
@ -28,6 +28,7 @@ type ResourceHandler[T ResourceHolder] interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Resource struct {
|
type Resource struct {
|
||||||
|
ID string `json:"-"`
|
||||||
Schemas []schemas.ScimSchemaType `json:"schemas"`
|
Schemas []schemas.ScimSchemaType `json:"schemas"`
|
||||||
Meta *ResourceMeta `json:"meta"`
|
Meta *ResourceMeta `json:"meta"`
|
||||||
}
|
}
|
||||||
@ -41,9 +42,14 @@ type ResourceMeta struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ResourceHolder interface {
|
type ResourceHolder interface {
|
||||||
|
SchemasHolder
|
||||||
GetResource() *Resource
|
GetResource() *Resource
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SchemasHolder interface {
|
||||||
|
GetSchemas() []schemas.ScimSchemaType
|
||||||
|
}
|
||||||
|
|
||||||
func buildResource[T ResourceHolder](ctx context.Context, handler ResourceHandler[T], details *domain.ObjectDetails) *Resource {
|
func buildResource[T ResourceHolder](ctx context.Context, handler ResourceHandler[T], details *domain.ObjectDetails) *Resource {
|
||||||
created := details.CreationDate.UTC()
|
created := details.CreationDate.UTC()
|
||||||
if created.IsZero() {
|
if created.IsZero() {
|
||||||
@ -51,17 +57,18 @@ func buildResource[T ResourceHolder](ctx context.Context, handler ResourceHandle
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &Resource{
|
return &Resource{
|
||||||
|
ID: details.ID,
|
||||||
Schemas: []schemas.ScimSchemaType{handler.SchemaType()},
|
Schemas: []schemas.ScimSchemaType{handler.SchemaType()},
|
||||||
Meta: &ResourceMeta{
|
Meta: &ResourceMeta{
|
||||||
ResourceType: handler.ResourceNameSingular(),
|
ResourceType: handler.ResourceNameSingular(),
|
||||||
Created: created,
|
Created: created,
|
||||||
LastModified: details.EventDate.UTC(),
|
LastModified: details.EventDate.UTC(),
|
||||||
Version: strconv.FormatUint(details.Sequence, 10),
|
Version: strconv.FormatUint(details.Sequence, 10),
|
||||||
Location: buildLocation(ctx, handler, details.ID),
|
Location: buildLocation(ctx, handler.ResourceNamePlural(), details.ID),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildLocation[T ResourceHolder](ctx context.Context, handler ResourceHandler[T], id string) string {
|
func buildLocation(ctx context.Context, resourceName schemas.ScimResourceTypePlural, id string) string {
|
||||||
return http.DomainContext(ctx).Origin() + path.Join(schemas.HandlerPrefix, authz.GetCtxData(ctx).OrgID, string(handler.ResourceNamePlural()), id)
|
return http.DomainContext(ctx).Origin() + path.Join(schemas.HandlerPrefix, authz.GetCtxData(ctx).OrgID, string(resourceName), id)
|
||||||
}
|
}
|
||||||
|
@ -1,17 +1,32 @@
|
|||||||
package resources
|
package resources
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"slices"
|
"slices"
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
|
"github.com/zitadel/logging"
|
||||||
|
|
||||||
"github.com/zitadel/zitadel/internal/api/scim/resources/patch"
|
"github.com/zitadel/zitadel/internal/api/scim/resources/patch"
|
||||||
|
"github.com/zitadel/zitadel/internal/api/scim/schemas"
|
||||||
"github.com/zitadel/zitadel/internal/api/scim/serrors"
|
"github.com/zitadel/zitadel/internal/api/scim/serrors"
|
||||||
"github.com/zitadel/zitadel/internal/zerrors"
|
"github.com/zitadel/zitadel/internal/zerrors"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// RawResourceHandlerAdapter adapts the ResourceHandler[T] without any generics
|
||||||
|
type RawResourceHandlerAdapter interface {
|
||||||
|
ResourceNamePlural() schemas.ScimResourceTypePlural
|
||||||
|
|
||||||
|
Create(ctx context.Context, data io.ReadCloser) (ResourceHolder, error)
|
||||||
|
Replace(ctx context.Context, resourceID string, data io.ReadCloser) (ResourceHolder, error)
|
||||||
|
Update(ctx context.Context, resourceID string, data io.ReadCloser) error
|
||||||
|
Delete(ctx context.Context, resourceID string) error
|
||||||
|
}
|
||||||
|
|
||||||
type ResourceHandlerAdapter[T ResourceHolder] struct {
|
type ResourceHandlerAdapter[T ResourceHolder] struct {
|
||||||
handler ResourceHandler[T]
|
handler ResourceHandler[T]
|
||||||
}
|
}
|
||||||
@ -22,38 +37,47 @@ func NewResourceHandlerAdapter[T ResourceHolder](handler ResourceHandler[T]) *Re
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (adapter *ResourceHandlerAdapter[T]) Create(r *http.Request) (T, error) {
|
func (adapter *ResourceHandlerAdapter[T]) ResourceNamePlural() schemas.ScimResourceTypePlural {
|
||||||
entity, err := adapter.readEntityFromBody(r)
|
return adapter.handler.ResourceNamePlural()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (adapter *ResourceHandlerAdapter[T]) CreateFromHttp(r *http.Request) (ResourceHolder, error) {
|
||||||
|
return adapter.Create(r.Context(), r.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (adapter *ResourceHandlerAdapter[T]) Create(ctx context.Context, data io.ReadCloser) (ResourceHolder, error) {
|
||||||
|
entity, err := adapter.readEntity(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return entity, err
|
return entity, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return adapter.handler.Create(r.Context(), entity)
|
return adapter.handler.Create(ctx, entity)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (adapter *ResourceHandlerAdapter[T]) Replace(r *http.Request) (T, error) {
|
func (adapter *ResourceHandlerAdapter[T]) ReplaceFromHttp(r *http.Request) (ResourceHolder, error) {
|
||||||
entity, err := adapter.readEntityFromBody(r)
|
return adapter.Replace(r.Context(), mux.Vars(r)["id"], r.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (adapter *ResourceHandlerAdapter[T]) Replace(ctx context.Context, resourceID string, data io.ReadCloser) (ResourceHolder, error) {
|
||||||
|
entity, err := adapter.readEntity(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return entity, err
|
return entity, err
|
||||||
}
|
}
|
||||||
|
|
||||||
id := mux.Vars(r)["id"]
|
return adapter.handler.Replace(ctx, resourceID, entity)
|
||||||
return adapter.handler.Replace(r.Context(), id, entity)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (adapter *ResourceHandlerAdapter[T]) Update(r *http.Request) error {
|
func (adapter *ResourceHandlerAdapter[T]) UpdateFromHttp(r *http.Request) error {
|
||||||
|
return adapter.Update(r.Context(), mux.Vars(r)["id"], r.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (adapter *ResourceHandlerAdapter[T]) Update(ctx context.Context, resourceID string, data io.ReadCloser) error {
|
||||||
request := new(patch.OperationRequest)
|
request := new(patch.OperationRequest)
|
||||||
err := json.NewDecoder(r.Body).Decode(request)
|
if err := readSchema(data, request, schemas.IdPatchOperation); err != nil {
|
||||||
if err != nil {
|
return err
|
||||||
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 := request.Validate(); err != nil {
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -61,17 +85,19 @@ func (adapter *ResourceHandlerAdapter[T]) Update(r *http.Request) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
id := mux.Vars(r)["id"]
|
return adapter.handler.Update(ctx, resourceID, request.Operations)
|
||||||
return adapter.handler.Update(r.Context(), id, request.Operations)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (adapter *ResourceHandlerAdapter[T]) Delete(r *http.Request) error {
|
func (adapter *ResourceHandlerAdapter[T]) DeleteFromHttp(r *http.Request) error {
|
||||||
id := mux.Vars(r)["id"]
|
return adapter.Delete(r.Context(), mux.Vars(r)["id"])
|
||||||
return adapter.handler.Delete(r.Context(), id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (adapter *ResourceHandlerAdapter[T]) List(r *http.Request) (*ListResponse[T], error) {
|
func (adapter *ResourceHandlerAdapter[T]) Delete(ctx context.Context, resourceID string) error {
|
||||||
request, err := readListRequest(r)
|
return adapter.handler.Delete(ctx, resourceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (adapter *ResourceHandlerAdapter[T]) ListFromHttp(r *http.Request) (*ListResponse[T], error) {
|
||||||
|
request, err := adapter.readListRequest(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -79,30 +105,40 @@ func (adapter *ResourceHandlerAdapter[T]) List(r *http.Request) (*ListResponse[T
|
|||||||
return adapter.handler.List(r.Context(), request)
|
return adapter.handler.List(r.Context(), request)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (adapter *ResourceHandlerAdapter[T]) Get(r *http.Request) (T, error) {
|
func (adapter *ResourceHandlerAdapter[T]) GetFromHttp(r *http.Request) (T, error) {
|
||||||
id := mux.Vars(r)["id"]
|
id := mux.Vars(r)["id"]
|
||||||
return adapter.handler.Get(r.Context(), id)
|
return adapter.handler.Get(r.Context(), id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (adapter *ResourceHandlerAdapter[T]) readEntityFromBody(r *http.Request) (T, error) {
|
func (adapter *ResourceHandlerAdapter[T]) readEntity(data io.ReadCloser) (T, error) {
|
||||||
entity := adapter.handler.NewResource()
|
entity := adapter.handler.NewResource()
|
||||||
err := json.NewDecoder(r.Body).Decode(entity)
|
return entity, readSchema(data, entity, adapter.handler.SchemaType())
|
||||||
|
}
|
||||||
|
|
||||||
|
func readSchema(data io.ReadCloser, entity SchemasHolder, schema schemas.ScimSchemaType) error {
|
||||||
|
defer func() {
|
||||||
|
err := data.Close()
|
||||||
|
logging.OnError(err).Warn("Failed to close http request body")
|
||||||
|
}()
|
||||||
|
|
||||||
|
err := json.NewDecoder(data).Decode(&entity)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if serrors.IsScimOrZitadelError(err) {
|
var maxBytesErr *http.MaxBytesError
|
||||||
return entity, err
|
if errors.As(err, &maxBytesErr) {
|
||||||
|
return serrors.ThrowPayloadTooLarge(zerrors.ThrowInvalidArgumentf(err, "SCIM-hmaxb1", "Request payload too large, max %d bytes allowed.", maxBytesErr.Limit))
|
||||||
}
|
}
|
||||||
|
|
||||||
return entity, serrors.ThrowInvalidSyntax(zerrors.ThrowInvalidArgumentf(nil, "SCIM-ucrjson", "Could not deserialize json: %v", err.Error()))
|
if serrors.IsScimOrZitadelError(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return serrors.ThrowInvalidSyntax(zerrors.ThrowInvalidArgumentf(err, "SCIM-ucrjson", "Could not deserialize json"))
|
||||||
}
|
}
|
||||||
|
|
||||||
resource := entity.GetResource()
|
providedSchemas := entity.GetSchemas()
|
||||||
if resource == nil {
|
if !slices.Contains(providedSchemas, schema) {
|
||||||
return entity, serrors.ThrowInvalidSyntax(zerrors.ThrowInvalidArgument(nil, "SCIM-xxrjson", "Could not get resource, is the schema correct?"))
|
return serrors.ThrowInvalidSyntax(zerrors.ThrowInvalidArgumentf(nil, "SCIM-xxrschema", "Expected schema %v is not provided", schema))
|
||||||
}
|
}
|
||||||
|
|
||||||
if !slices.Contains(resource.Schemas, adapter.handler.SchemaType()) {
|
return nil
|
||||||
return entity, serrors.ThrowInvalidSyntax(zerrors.ThrowInvalidArgumentf(nil, "SCIM-xxrschema", "Expected schema %v is not provided", adapter.handler.SchemaType()))
|
|
||||||
}
|
|
||||||
|
|
||||||
return entity, nil
|
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
package resources
|
package resources
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
zhttp "github.com/zitadel/zitadel/internal/api/http"
|
zhttp "github.com/zitadel/zitadel/internal/api/http"
|
||||||
@ -13,6 +12,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type ListRequest struct {
|
type ListRequest struct {
|
||||||
|
Schemas []schemas.ScimSchemaType `json:"schemas"`
|
||||||
|
|
||||||
// Count An integer indicating the desired maximum number of query results per page.
|
// Count An integer indicating the desired maximum number of query results per page.
|
||||||
Count int64 `json:"count" schema:"count"`
|
Count int64 `json:"count" schema:"count"`
|
||||||
|
|
||||||
@ -47,6 +48,10 @@ const (
|
|||||||
|
|
||||||
var parser = zhttp.NewParser()
|
var parser = zhttp.NewParser()
|
||||||
|
|
||||||
|
func (r *ListRequest) GetSchemas() []schemas.ScimSchemaType {
|
||||||
|
return r.Schemas
|
||||||
|
}
|
||||||
|
|
||||||
func (o ListRequestSortOrder) isDefined() bool {
|
func (o ListRequestSortOrder) isDefined() bool {
|
||||||
switch o {
|
switch o {
|
||||||
case ListRequestSortOrderAsc, ListRequestSortOrderDsc:
|
case ListRequestSortOrderAsc, ListRequestSortOrderDsc:
|
||||||
@ -70,7 +75,7 @@ func newListResponse[T ResourceHolder](totalResultCount uint64, q query.SearchRe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func readListRequest(r *http.Request) (*ListRequest, error) {
|
func (adapter *ResourceHandlerAdapter[T]) readListRequest(r *http.Request) (*ListRequest, error) {
|
||||||
request := &ListRequest{
|
request := &ListRequest{
|
||||||
Count: defaultListCount,
|
Count: defaultListCount,
|
||||||
StartIndex: 1,
|
StartIndex: 1,
|
||||||
@ -89,12 +94,8 @@ func readListRequest(r *http.Request) (*ListRequest, error) {
|
|||||||
return nil, zerrors.ThrowInvalidArgument(nil, "SCIM-ullform", "Could not decode form: "+err.Error())
|
return nil, zerrors.ThrowInvalidArgument(nil, "SCIM-ullform", "Could not decode form: "+err.Error())
|
||||||
}
|
}
|
||||||
case http.MethodPost:
|
case http.MethodPost:
|
||||||
if err := json.NewDecoder(r.Body).Decode(request); err != nil {
|
if err := readSchema(r.Body, request, schemas.IdSearchRequest); err != nil {
|
||||||
if serrors.IsScimOrZitadelError(err) {
|
return nil, err
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, zerrors.ThrowInvalidArgument(nil, "SCIM-ulljson", "Could not decode json: "+err.Error())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// json deserialization initializes this field if an empty string is provided
|
// json deserialization initializes this field if an empty string is provided
|
||||||
|
@ -124,6 +124,13 @@ func (h *UsersHandler) ResourceNamePlural() scim_schemas.ScimResourceTypePlural
|
|||||||
func (u *ScimUser) GetResource() *Resource {
|
func (u *ScimUser) GetResource() *Resource {
|
||||||
return u.Resource
|
return u.Resource
|
||||||
}
|
}
|
||||||
|
func (u *ScimUser) GetSchemas() []scim_schemas.ScimSchemaType {
|
||||||
|
if u.Resource == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return u.Resource.Schemas
|
||||||
|
}
|
||||||
|
|
||||||
func (h *UsersHandler) NewResource() *ScimUser {
|
func (h *UsersHandler) NewResource() *ScimUser {
|
||||||
return new(ScimUser)
|
return new(ScimUser)
|
||||||
|
@ -384,13 +384,14 @@ func (h *UsersHandler) mapAndValidateMetadata(ctx context.Context, user *ScimUse
|
|||||||
|
|
||||||
func (h *UsersHandler) buildResourceForQuery(ctx context.Context, user *query.User) *Resource {
|
func (h *UsersHandler) buildResourceForQuery(ctx context.Context, user *query.User) *Resource {
|
||||||
return &Resource{
|
return &Resource{
|
||||||
|
ID: user.ID,
|
||||||
Schemas: []schemas.ScimSchemaType{schemas.IdUser},
|
Schemas: []schemas.ScimSchemaType{schemas.IdUser},
|
||||||
Meta: &ResourceMeta{
|
Meta: &ResourceMeta{
|
||||||
ResourceType: schemas.UserResourceType,
|
ResourceType: schemas.UserResourceType,
|
||||||
Created: user.CreationDate.UTC(),
|
Created: user.CreationDate.UTC(),
|
||||||
LastModified: user.ChangeDate.UTC(),
|
LastModified: user.ChangeDate.UTC(),
|
||||||
Version: strconv.FormatUint(user.Sequence, 10),
|
Version: strconv.FormatUint(user.Sequence, 10),
|
||||||
Location: buildLocation(ctx, h, user.ID),
|
Location: buildLocation(ctx, h.ResourceNamePlural(), user.ID),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -403,7 +404,7 @@ func (h *UsersHandler) buildResourceForWriteModel(ctx context.Context, user *com
|
|||||||
Created: user.CreationDate.UTC(),
|
Created: user.CreationDate.UTC(),
|
||||||
LastModified: user.ChangeDate.UTC(),
|
LastModified: user.ChangeDate.UTC(),
|
||||||
Version: strconv.FormatUint(user.ProcessedSequence, 10),
|
Version: strconv.FormatUint(user.ProcessedSequence, 10),
|
||||||
Location: buildLocation(ctx, h, user.AggregateID),
|
Location: buildLocation(ctx, h.ResourceNamePlural(), user.AggregateID),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -12,6 +12,9 @@ const (
|
|||||||
IdUser ScimSchemaType = idPrefixCore + "User"
|
IdUser ScimSchemaType = idPrefixCore + "User"
|
||||||
IdListResponse ScimSchemaType = idPrefixMessages + "ListResponse"
|
IdListResponse ScimSchemaType = idPrefixMessages + "ListResponse"
|
||||||
IdPatchOperation ScimSchemaType = idPrefixMessages + "PatchOp"
|
IdPatchOperation ScimSchemaType = idPrefixMessages + "PatchOp"
|
||||||
|
IdSearchRequest ScimSchemaType = idPrefixMessages + "SearchRequest"
|
||||||
|
IdBulkRequest ScimSchemaType = idPrefixMessages + "BulkRequest"
|
||||||
|
IdBulkResponse ScimSchemaType = idPrefixMessages + "BulkResponse"
|
||||||
IdError ScimSchemaType = idPrefixMessages + "Error"
|
IdError ScimSchemaType = idPrefixMessages + "Error"
|
||||||
IdZitadelErrorDetail ScimSchemaType = idPrefixZitadelMessages + "ErrorDetail"
|
IdZitadelErrorDetail ScimSchemaType = idPrefixZitadelMessages + "ErrorDetail"
|
||||||
|
|
||||||
|
@ -23,18 +23,19 @@ type scimErrorType string
|
|||||||
type wrappedScimError struct {
|
type wrappedScimError struct {
|
||||||
Parent error
|
Parent error
|
||||||
ScimType scimErrorType
|
ScimType scimErrorType
|
||||||
|
Status int
|
||||||
}
|
}
|
||||||
|
|
||||||
type scimError struct {
|
type ScimError struct {
|
||||||
Schemas []schemas.ScimSchemaType `json:"schemas"`
|
Schemas []schemas.ScimSchemaType `json:"schemas"`
|
||||||
ScimType scimErrorType `json:"scimType,omitempty"`
|
ScimType scimErrorType `json:"scimType,omitempty"`
|
||||||
Detail string `json:"detail,omitempty"`
|
Detail string `json:"detail,omitempty"`
|
||||||
StatusCode int `json:"-"`
|
StatusCode int `json:"-"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
ZitadelDetail *errorDetail `json:"urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail,omitempty"`
|
ZitadelDetail *ErrorDetail `json:"urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type errorDetail struct {
|
type ErrorDetail struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
}
|
}
|
||||||
@ -78,7 +79,7 @@ func ErrorHandler(next zhttp_middleware.HandlerFuncWithError) http.Handler {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
scimErr := mapToScimJsonError(r.Context(), err)
|
scimErr := MapToScimError(r.Context(), err)
|
||||||
w.WriteHeader(scimErr.StatusCode)
|
w.WriteHeader(scimErr.StatusCode)
|
||||||
|
|
||||||
jsonErr := json.NewEncoder(w).Encode(scimErr)
|
jsonErr := json.NewEncoder(w).Encode(scimErr)
|
||||||
@ -121,6 +122,13 @@ func ThrowNoTarget(parent error) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ThrowPayloadTooLarge(parent error) error {
|
||||||
|
return &wrappedScimError{
|
||||||
|
Parent: parent,
|
||||||
|
Status: http.StatusRequestEntityTooLarge,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func IsScimOrZitadelError(err error) bool {
|
func IsScimOrZitadelError(err error) bool {
|
||||||
return IsScimError(err) || zerrors.IsZitadelError(err)
|
return IsScimError(err) || zerrors.IsZitadelError(err)
|
||||||
}
|
}
|
||||||
@ -130,7 +138,7 @@ func IsScimError(err error) bool {
|
|||||||
return errors.As(err, &scimErr)
|
return errors.As(err, &scimErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (err *scimError) Error() string {
|
func (err *ScimError) Error() string {
|
||||||
return fmt.Sprintf("SCIM Error: %s: %s", err.ScimType, err.Detail)
|
return fmt.Sprintf("SCIM Error: %s: %s", err.ScimType, err.Detail)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -138,17 +146,30 @@ func (err *wrappedScimError) Error() string {
|
|||||||
return fmt.Sprintf("SCIM Error: %s: %s", err.ScimType, err.Parent.Error())
|
return fmt.Sprintf("SCIM Error: %s: %s", err.ScimType, err.Parent.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
func mapToScimJsonError(ctx context.Context, err error) *scimError {
|
func MapToScimError(ctx context.Context, err error) *ScimError {
|
||||||
scimErr := new(wrappedScimError)
|
scimError := new(ScimError)
|
||||||
if ok := errors.As(err, &scimErr); ok {
|
if ok := errors.As(err, &scimError); ok {
|
||||||
mappedErr := mapToScimJsonError(ctx, scimErr.Parent)
|
return scimError
|
||||||
mappedErr.ScimType = scimErr.ScimType
|
}
|
||||||
|
|
||||||
|
scimWrappedError := new(wrappedScimError)
|
||||||
|
if ok := errors.As(err, &scimWrappedError); ok {
|
||||||
|
mappedErr := MapToScimError(ctx, scimWrappedError.Parent)
|
||||||
|
if scimWrappedError.ScimType != "" {
|
||||||
|
mappedErr.ScimType = scimWrappedError.ScimType
|
||||||
|
}
|
||||||
|
|
||||||
|
if scimWrappedError.Status != 0 {
|
||||||
|
mappedErr.Status = strconv.Itoa(scimWrappedError.Status)
|
||||||
|
mappedErr.StatusCode = scimWrappedError.Status
|
||||||
|
}
|
||||||
|
|
||||||
return mappedErr
|
return mappedErr
|
||||||
}
|
}
|
||||||
|
|
||||||
zitadelErr := new(zerrors.ZitadelError)
|
zitadelErr := new(zerrors.ZitadelError)
|
||||||
if ok := errors.As(err, &zitadelErr); !ok {
|
if ok := errors.As(err, &zitadelErr); !ok {
|
||||||
return &scimError{
|
return &ScimError{
|
||||||
Schemas: []schemas.ScimSchemaType{schemas.IdError},
|
Schemas: []schemas.ScimSchemaType{schemas.IdError},
|
||||||
Detail: "Unknown internal server error",
|
Detail: "Unknown internal server error",
|
||||||
Status: strconv.Itoa(http.StatusInternalServerError),
|
Status: strconv.Itoa(http.StatusInternalServerError),
|
||||||
@ -162,13 +183,13 @@ func mapToScimJsonError(ctx context.Context, err error) *scimError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
localizedMsg := translator.LocalizeFromCtx(ctx, zitadelErr.GetMessage(), nil)
|
localizedMsg := translator.LocalizeFromCtx(ctx, zitadelErr.GetMessage(), nil)
|
||||||
return &scimError{
|
return &ScimError{
|
||||||
Schemas: []schemas.ScimSchemaType{schemas.IdError, schemas.IdZitadelErrorDetail},
|
Schemas: []schemas.ScimSchemaType{schemas.IdError, schemas.IdZitadelErrorDetail},
|
||||||
ScimType: mapErrorToScimErrorType(err),
|
ScimType: mapErrorToScimErrorType(err),
|
||||||
Detail: localizedMsg,
|
Detail: localizedMsg,
|
||||||
StatusCode: statusCode,
|
StatusCode: statusCode,
|
||||||
Status: strconv.Itoa(statusCode),
|
Status: strconv.Itoa(statusCode),
|
||||||
ZitadelDetail: &errorDetail{
|
ZitadelDetail: &ErrorDetail{
|
||||||
ID: zitadelErr.GetID(),
|
ID: zitadelErr.GetID(),
|
||||||
Message: zitadelErr.GetMessage(),
|
Message: zitadelErr.GetMessage(),
|
||||||
},
|
},
|
||||||
|
@ -27,7 +27,8 @@ func NewServer(
|
|||||||
verifier *authz.ApiTokenVerifier,
|
verifier *authz.ApiTokenVerifier,
|
||||||
userCodeAlg crypto.EncryptionAlgorithm,
|
userCodeAlg crypto.EncryptionAlgorithm,
|
||||||
config *sconfig.Config,
|
config *sconfig.Config,
|
||||||
middlewares ...zhttp_middlware.MiddlewareWithErrorFunc) http.Handler {
|
middlewares ...zhttp_middlware.MiddlewareWithErrorFunc,
|
||||||
|
) http.Handler {
|
||||||
verifier.RegisterServer("SCIM-V2", schemas.HandlerPrefix, AuthMapping)
|
verifier.RegisterServer("SCIM-V2", schemas.HandlerPrefix, AuthMapping)
|
||||||
return buildHandler(command, query, userCodeAlg, config, middlewares...)
|
return buildHandler(command, query, userCodeAlg, config, middlewares...)
|
||||||
}
|
}
|
||||||
@ -37,29 +38,41 @@ func buildHandler(
|
|||||||
query *query.Queries,
|
query *query.Queries,
|
||||||
userCodeAlg crypto.EncryptionAlgorithm,
|
userCodeAlg crypto.EncryptionAlgorithm,
|
||||||
cfg *sconfig.Config,
|
cfg *sconfig.Config,
|
||||||
middlewares ...zhttp_middlware.MiddlewareWithErrorFunc) http.Handler {
|
middlewares ...zhttp_middlware.MiddlewareWithErrorFunc,
|
||||||
|
) http.Handler {
|
||||||
|
|
||||||
router := mux.NewRouter()
|
router := mux.NewRouter()
|
||||||
|
middleware := buildMiddleware(cfg, query, middlewares)
|
||||||
|
|
||||||
|
usersHandler := sresources.NewResourceHandlerAdapter(sresources.NewUsersHandler(command, query, userCodeAlg, cfg))
|
||||||
|
mapResource(router, middleware, usersHandler)
|
||||||
|
|
||||||
|
bulkHandler := sresources.NewBulkHandler(cfg.Bulk, usersHandler)
|
||||||
|
router.Handle("/"+zhttp.OrgIdInPathVariable+"/Bulk", middleware(handleJsonResponse(bulkHandler.BulkFromHttp))).Methods(http.MethodPost)
|
||||||
|
|
||||||
|
return router
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildMiddleware(cfg *sconfig.Config, query *query.Queries, middlewares []zhttp_middlware.MiddlewareWithErrorFunc) zhttp_middlware.ErrorHandlerFunc {
|
||||||
// content type middleware needs to run at the very beginning to correctly set content types of errors
|
// content type middleware needs to run at the very beginning to correctly set content types of errors
|
||||||
middlewares = append([]zhttp_middlware.MiddlewareWithErrorFunc{smiddleware.ContentTypeMiddleware}, middlewares...)
|
middlewares = append([]zhttp_middlware.MiddlewareWithErrorFunc{smiddleware.ContentTypeMiddleware}, middlewares...)
|
||||||
middlewares = append(middlewares, smiddleware.ScimContextMiddleware(query))
|
middlewares = append(middlewares, smiddleware.ScimContextMiddleware(query))
|
||||||
scimMiddleware := zhttp_middlware.ChainedWithErrorHandler(serrors.ErrorHandler, middlewares...)
|
scimMiddleware := zhttp_middlware.ChainedWithErrorHandler(serrors.ErrorHandler, middlewares...)
|
||||||
mapResource(router, scimMiddleware, sresources.NewUsersHandler(command, query, userCodeAlg, cfg))
|
return func(handler zhttp_middlware.HandlerFuncWithError) http.Handler {
|
||||||
return router
|
return http.MaxBytesHandler(scimMiddleware(handler), cfg.MaxRequestBodySize)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func mapResource[T sresources.ResourceHolder](router *mux.Router, mw zhttp_middlware.ErrorHandlerFunc, handler sresources.ResourceHandler[T]) {
|
func mapResource[T sresources.ResourceHolder](router *mux.Router, mw zhttp_middlware.ErrorHandlerFunc, adapter *sresources.ResourceHandlerAdapter[T]) {
|
||||||
adapter := sresources.NewResourceHandlerAdapter[T](handler)
|
resourceRouter := router.PathPrefix("/" + path.Join(zhttp.OrgIdInPathVariable, string(adapter.ResourceNamePlural()))).Subrouter()
|
||||||
resourceRouter := router.PathPrefix("/" + path.Join(zhttp.OrgIdInPathVariable, string(handler.ResourceNamePlural()))).Subrouter()
|
|
||||||
|
|
||||||
resourceRouter.Handle("", mw(handleResourceCreatedResponse(adapter.Create))).Methods(http.MethodPost)
|
resourceRouter.Handle("", mw(handleResourceCreatedResponse(adapter.CreateFromHttp))).Methods(http.MethodPost)
|
||||||
resourceRouter.Handle("", mw(handleJsonResponse(adapter.List))).Methods(http.MethodGet)
|
resourceRouter.Handle("", mw(handleJsonResponse(adapter.ListFromHttp))).Methods(http.MethodGet)
|
||||||
resourceRouter.Handle("/.search", mw(handleJsonResponse(adapter.List))).Methods(http.MethodPost)
|
resourceRouter.Handle("/.search", mw(handleJsonResponse(adapter.ListFromHttp))).Methods(http.MethodPost)
|
||||||
resourceRouter.Handle("/{id}", mw(handleResourceResponse(adapter.Get))).Methods(http.MethodGet)
|
resourceRouter.Handle("/{id}", mw(handleResourceResponse(adapter.GetFromHttp))).Methods(http.MethodGet)
|
||||||
resourceRouter.Handle("/{id}", mw(handleResourceResponse(adapter.Replace))).Methods(http.MethodPut)
|
resourceRouter.Handle("/{id}", mw(handleResourceResponse(adapter.ReplaceFromHttp))).Methods(http.MethodPut)
|
||||||
resourceRouter.Handle("/{id}", mw(handleEmptyResponse(adapter.Update))).Methods(http.MethodPatch)
|
resourceRouter.Handle("/{id}", mw(handleEmptyResponse(adapter.UpdateFromHttp))).Methods(http.MethodPatch)
|
||||||
resourceRouter.Handle("/{id}", mw(handleEmptyResponse(adapter.Delete))).Methods(http.MethodDelete)
|
resourceRouter.Handle("/{id}", mw(handleEmptyResponse(adapter.DeleteFromHttp))).Methods(http.MethodDelete)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleJsonResponse[T any](next func(r *http.Request) (T, error)) zhttp_middlware.HandlerFuncWithError {
|
func handleJsonResponse[T any](next func(r *http.Request) (T, error)) zhttp_middlware.HandlerFuncWithError {
|
||||||
|
@ -21,12 +21,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
Users *ResourceClient[resources.ScimUser]
|
client *http.Client
|
||||||
|
baseURL string
|
||||||
|
Users *ResourceClient[resources.ScimUser]
|
||||||
}
|
}
|
||||||
|
|
||||||
type ResourceClient[T any] struct {
|
type ResourceClient[T any] struct {
|
||||||
client *http.Client
|
client *http.Client
|
||||||
baseUrl string
|
baseURL string
|
||||||
resourceName string
|
resourceName string
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -44,6 +46,8 @@ type ZitadelErrorDetail struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ListRequest struct {
|
type ListRequest struct {
|
||||||
|
Schemas []schemas.ScimSchemaType `json:"schemas"`
|
||||||
|
|
||||||
Count *int `json:"count,omitempty"`
|
Count *int `json:"count,omitempty"`
|
||||||
|
|
||||||
// StartIndex An integer indicating the 1-based index of the first query result.
|
// StartIndex An integer indicating the 1-based index of the first query result.
|
||||||
@ -73,6 +77,32 @@ type ListResponse[T any] struct {
|
|||||||
Resources []T `json:"Resources"`
|
Resources []T `json:"Resources"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type BulkRequest struct {
|
||||||
|
Schemas []schemas.ScimSchemaType `json:"schemas"`
|
||||||
|
FailOnErrors *int `json:"failOnErrors"`
|
||||||
|
Operations []*BulkRequestOperation `json:"Operations"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BulkRequestOperation struct {
|
||||||
|
Method string `json:"method"`
|
||||||
|
BulkID string `json:"bulkId"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Data json.RawMessage `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BulkResponse struct {
|
||||||
|
Schemas []schemas.ScimSchemaType `json:"schemas"`
|
||||||
|
Operations []*BulkResponseOperation `json:"Operations"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BulkResponseOperation struct {
|
||||||
|
Method string `json:"method"`
|
||||||
|
BulkID string `json:"bulkId,omitempty"`
|
||||||
|
Location string `json:"location,omitempty"`
|
||||||
|
Response *ScimError `json:"response,omitempty"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
listQueryParamSortBy = "sortBy"
|
listQueryParamSortBy = "sortBy"
|
||||||
listQueryParamSortOrder = "sortOrder"
|
listQueryParamSortOrder = "sortOrder"
|
||||||
@ -85,14 +115,27 @@ func NewScimClient(target string) *Client {
|
|||||||
target = "http://" + target + schemas.HandlerPrefix
|
target = "http://" + target + schemas.HandlerPrefix
|
||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
return &Client{
|
return &Client{
|
||||||
|
client: client,
|
||||||
|
baseURL: target,
|
||||||
Users: &ResourceClient[resources.ScimUser]{
|
Users: &ResourceClient[resources.ScimUser]{
|
||||||
client: client,
|
client: client,
|
||||||
baseUrl: target,
|
baseURL: target,
|
||||||
resourceName: "Users",
|
resourceName: "Users",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) Bulk(ctx context.Context, orgID string, body []byte) (*BulkResponse, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/"+orgID+"/Bulk", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set(zhttp.ContentType, middleware.ContentTypeScim)
|
||||||
|
resp := new(BulkResponse)
|
||||||
|
return resp, doReq(c.client, req, resp)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *ResourceClient[T]) Create(ctx context.Context, orgID string, body []byte) (*T, error) {
|
func (c *ResourceClient[T]) Create(ctx context.Context, orgID string, body []byte) (*T, error) {
|
||||||
return c.doWithBody(ctx, http.MethodPost, orgID, "", bytes.NewReader(body))
|
return c.doWithBody(ctx, http.MethodPost, orgID, "", bytes.NewReader(body))
|
||||||
}
|
}
|
||||||
@ -102,21 +145,25 @@ func (c *ResourceClient[T]) Replace(ctx context.Context, orgID, id string, body
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *ResourceClient[T]) Update(ctx context.Context, orgID, id string, body []byte) error {
|
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))
|
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, c.buildResourceURL(orgID, id), bytes.NewReader(body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.doReq(req, nil)
|
return doReq(c.client, 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) {
|
||||||
|
listResponse := new(ListResponse[*T])
|
||||||
|
|
||||||
if req.SendAsPost {
|
if req.SendAsPost {
|
||||||
listReq, err := json.Marshal(req)
|
listReq, err := json.Marshal(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return c.doWithListResponse(ctx, http.MethodPost, orgID, ".search", bytes.NewReader(listReq))
|
|
||||||
|
err = c.doWithResponse(ctx, http.MethodPost, orgID, ".search", bytes.NewReader(listReq), listResponse)
|
||||||
|
return listResponse, err
|
||||||
}
|
}
|
||||||
|
|
||||||
query, err := url.ParseQuery("")
|
query, err := url.ParseQuery("")
|
||||||
@ -144,7 +191,8 @@ func (c *ResourceClient[T]) List(ctx context.Context, orgID string, req *ListReq
|
|||||||
query.Set(listQueryParamFilter, *req.Filter)
|
query.Set(listQueryParamFilter, *req.Filter)
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.doWithListResponse(ctx, http.MethodGet, orgID, "?"+query.Encode(), nil)
|
err = c.doWithResponse(ctx, http.MethodGet, orgID, "?"+query.Encode(), nil, listResponse)
|
||||||
|
return listResponse, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *ResourceClient[T]) Get(ctx context.Context, orgID, resourceID string) (*T, error) {
|
func (c *ResourceClient[T]) Get(ctx context.Context, orgID, resourceID string) (*T, error) {
|
||||||
@ -156,40 +204,39 @@ func (c *ResourceClient[T]) Delete(ctx context.Context, orgID, id string) error
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *ResourceClient[T]) do(ctx context.Context, method, orgID, url string) error {
|
func (c *ResourceClient[T]) do(ctx context.Context, method, orgID, url string) error {
|
||||||
req, err := http.NewRequestWithContext(ctx, method, c.buildURL(orgID, url), nil)
|
req, err := http.NewRequestWithContext(ctx, method, c.buildResourceURL(orgID, url), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.doReq(req, nil)
|
return doReq(c.client, req, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *ResourceClient[T]) doWithListResponse(ctx context.Context, method, orgID, url string, body io.Reader) (*ListResponse[*T], error) {
|
func (c *ResourceClient[T]) doWithResponse(ctx context.Context, method, orgID, url string, body io.Reader, response interface{}) error {
|
||||||
req, err := http.NewRequestWithContext(ctx, method, c.buildURL(orgID, url), body)
|
req, err := http.NewRequestWithContext(ctx, method, c.buildResourceURL(orgID, url), body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set(zhttp.ContentType, middleware.ContentTypeScim)
|
req.Header.Set(zhttp.ContentType, middleware.ContentTypeScim)
|
||||||
response := new(ListResponse[*T])
|
return doReq(c.client, req, response)
|
||||||
return response, c.doReq(req, response)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *ResourceClient[T]) doWithBody(ctx context.Context, method, orgID, url string, body io.Reader) (*T, error) {
|
func (c *ResourceClient[T]) doWithBody(ctx context.Context, method, orgID, url string, body io.Reader) (*T, error) {
|
||||||
req, err := http.NewRequestWithContext(ctx, method, c.buildURL(orgID, url), body)
|
req, err := http.NewRequestWithContext(ctx, method, c.buildResourceURL(orgID, url), body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set(zhttp.ContentType, middleware.ContentTypeScim)
|
req.Header.Set(zhttp.ContentType, middleware.ContentTypeScim)
|
||||||
responseEntity := new(T)
|
responseEntity := new(T)
|
||||||
return responseEntity, c.doReq(req, responseEntity)
|
return responseEntity, doReq(c.client, req, responseEntity)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *ResourceClient[T]) doReq(req *http.Request, responseEntity interface{}) error {
|
func doReq(client *http.Client, req *http.Request, responseEntity interface{}) error {
|
||||||
addTokenAsHeader(req)
|
addTokenAsHeader(req)
|
||||||
|
|
||||||
resp, err := c.client.Do(req)
|
resp, err := client.Do(req)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@ -239,12 +286,12 @@ func readScimError(resp *http.Response) error {
|
|||||||
return scimErr
|
return scimErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *ResourceClient[T]) buildURL(orgID, segment string) string {
|
func (c *ResourceClient[T]) buildResourceURL(orgID, segment string) string {
|
||||||
if segment == "" || strings.HasPrefix(segment, "?") {
|
if segment == "" || strings.HasPrefix(segment, "?") {
|
||||||
return c.baseUrl + "/" + path.Join(orgID, c.resourceName) + segment
|
return c.baseURL + "/" + path.Join(orgID, c.resourceName) + segment
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.baseUrl + "/" + path.Join(orgID, c.resourceName, segment)
|
return c.baseURL + "/" + path.Join(orgID, c.resourceName, segment)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (err *ScimError) Error() string {
|
func (err *ScimError) Error() string {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user