diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index 326dcc69a8..1adbf0fa4e 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -583,6 +583,9 @@ SCIM: # default values whether an email/phone is considered verified when a users email/phone is created or updated EmailVerified: true # ZITADEL_SCIM_EMAILVERIFIED PhoneVerified: true # ZITADEL_SCIM_PHONEVERIFIED + MaxRequestBodySize: 1_000_000 # ZITADEL_SCIM_MAXREQUESTBODYSIZE + Bulk: + MaxOperationsCount: 100 # ZITADEL_SCIM_BULK_MAXOPERATIONSCOUNT Login: LanguageCookieName: zitadel.login.lang # ZITADEL_LOGIN_LANGUAGECOOKIENAME diff --git a/internal/api/scim/authz.go b/internal/api/scim/authz.go index 245c8c7a54..27d179e605 100644 --- a/internal/api/scim/authz.go +++ b/internal/api/scim/authz.go @@ -28,4 +28,7 @@ var AuthMapping = authz.MethodMapping{ "DELETE:/scim/v2/" + http.OrgIdInPathVariable + "/Users/{id}": { Permission: domain.PermissionUserDelete, }, + "POST:/scim/v2/" + http.OrgIdInPathVariable + "/Bulk": { + Permission: "authenticated", + }, } diff --git a/internal/api/scim/config/config.go b/internal/api/scim/config/config.go index 6199f0a2ea..49c7d52a80 100644 --- a/internal/api/scim/config/config.go +++ b/internal/api/scim/config/config.go @@ -1,6 +1,12 @@ package config type Config struct { - EmailVerified bool - PhoneVerified bool + EmailVerified bool + PhoneVerified bool + MaxRequestBodySize int64 + Bulk BulkConfig +} + +type BulkConfig struct { + MaxOperationsCount int } diff --git a/internal/api/scim/integration_test/bulk_test.go b/internal/api/scim/integration_test/bulk_test.go new file mode 100644 index 0000000000..b3beb8dc38 --- /dev/null +++ b/internal/api/scim/integration_test/bulk_test.go @@ -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 +} diff --git a/internal/api/scim/integration_test/scim_test.go b/internal/api/scim/integration_test/scim_test.go index 84c4d96bec..7980a34337 100644 --- a/internal/api/scim/integration_test/scim_test.go +++ b/internal/api/scim/integration_test/scim_test.go @@ -5,6 +5,7 @@ package integration_test import ( "context" "os" + "regexp" "testing" "time" @@ -14,6 +15,10 @@ import ( var ( Instance *integration.Instance 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) { @@ -27,3 +32,7 @@ func TestMain(m *testing.M) { return m.Run() }()) } + +func removeComments(json []byte) []byte { + return removeCommentsRegex.ReplaceAll(json, nil) +} diff --git a/internal/api/scim/integration_test/testdata/bulk_test_errors.json b/internal/api/scim/integration_test/testdata/bulk_test_errors.json new file mode 100644 index 0000000000..93d1d8d77f --- /dev/null +++ b/internal/api/scim/integration_test/testdata/bulk_test_errors.json @@ -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!" + } + } + ] +} \ No newline at end of file diff --git a/internal/api/scim/integration_test/testdata/bulk_test_fail_on_errors.json b/internal/api/scim/integration_test/testdata/bulk_test_fail_on_errors.json new file mode 100644 index 0000000000..bc9ed1346a --- /dev/null +++ b/internal/api/scim/integration_test/testdata/bulk_test_fail_on_errors.json @@ -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!" + } + } + ] +} \ No newline at end of file diff --git a/internal/api/scim/integration_test/testdata/bulk_test_full.json b/internal/api/scim/integration_test/testdata/bulk_test_full.json new file mode 100644 index 0000000000..55f727cf82 --- /dev/null +++ b/internal/api/scim/integration_test/testdata/bulk_test_full.json @@ -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" + } + ] + } + } + ] +} \ No newline at end of file diff --git a/internal/api/scim/integration_test/users_list_test.go b/internal/api/scim/integration_test/users_list_test.go index 36dc96210d..3df97fae28 100644 --- a/internal/api/scim/integration_test/users_list_test.go +++ b/internal/api/scim/integration_test/users_list_test.go @@ -190,6 +190,7 @@ func TestListUser(t *testing.T) { { name: "list paged sorted users with filter as post", req: &scim.ListRequest{ + Schemas: []schemas.ScimSchemaType{schemas.IdSearchRequest}, Count: gu.Ptr(5), StartIndex: gu.Ptr(1), SortOrder: gu.Ptr(scim.ListRequestSortOrderAsc), diff --git a/internal/api/scim/integration_test/users_update_test.go b/internal/api/scim/integration_test/users_update_test.go index 9fc78b9284..f9a807c357 100644 --- a/internal/api/scim/integration_test/users_update_test.go +++ b/internal/api/scim/integration_test/users_update_test.go @@ -7,7 +7,6 @@ import ( _ "embed" "fmt" "net/http" - "regexp" "testing" "time" @@ -29,21 +28,13 @@ var ( //go:embed testdata/users_update_test_full.json fullUserUpdateJson []byte - minimalUserUpdateJson []byte = simpleReplacePatchBody("nickname", "foo") - - // remove comments in the json, as the default golang json unmarshaler cannot handle them - // the test file is much easier to maintain with comments - removeCommentsRegex = regexp.MustCompile("(?s)//.*?\n|/\\*.*?\\*/") + minimalUserUpdateJson = simpleReplacePatchBody("nickname", "foo") ) func init() { fullUserUpdateJson = removeComments(fullUserUpdateJson) } -func removeComments(json []byte) []byte { - return removeCommentsRegex.ReplaceAll(json, nil) -} - func TestUpdateUser(t *testing.T) { fullUserCreated, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, fullUserJson) require.NoError(t, err) diff --git a/internal/api/scim/metadata/context.go b/internal/api/scim/metadata/context.go index 5be54d7123..dd8ef54d8e 100644 --- a/internal/api/scim/metadata/context.go +++ b/internal/api/scim/metadata/context.go @@ -2,22 +2,57 @@ package metadata import ( "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 { ProvisioningDomain string 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 { - return context.WithValue(ctx, provisioningDomainKey, data) + return context.WithValue(ctx, scimContextKey, data) } func GetScimContextData(ctx context.Context) ScimContextData { - data, _ := ctx.Value(provisioningDomainKey).(ScimContextData) + data, _ := ctx.Value(scimContextKey).(ScimContextData) 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 +} diff --git a/internal/api/scim/middleware/scim_context_middleware.go b/internal/api/scim/middleware/scim_context_middleware.go index c52f6f13f6..1ec917e18b 100644 --- a/internal/api/scim/middleware/scim_context_middleware.go +++ b/internal/api/scim/middleware/scim_context_middleware.go @@ -25,11 +25,7 @@ func ScimContextMiddleware(q *query.Queries) func(next zhttp.HandlerFuncWithErro } func initScimContext(ctx context.Context, q *query.Queries) (context.Context, error) { - data := smetadata.ScimContextData{ - ProvisioningDomain: "", - ExternalIDScopedMetadataKey: smetadata.ScopedKey(smetadata.KeyExternalId), - } - + data := smetadata.NewScimContextData() ctx = smetadata.SetScimContextData(ctx, data) userID := authz.GetCtxData(ctx).UserID diff --git a/internal/api/scim/resources/bulk.go b/internal/api/scim/resources/bulk.go new file mode 100644 index 0000000000..6aaddeec2f --- /dev/null +++ b/internal/api/scim/resources/bulk.go @@ -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") + } +} diff --git a/internal/api/scim/resources/patch/patch.go b/internal/api/scim/resources/patch/patch.go index 208e23e3ac..f4e6b47101 100644 --- a/internal/api/scim/resources/patch/patch.go +++ b/internal/api/scim/resources/patch/patch.go @@ -3,7 +3,6 @@ package patch import ( "encoding/json" "reflect" - "slices" "strings" "github.com/zitadel/logging" @@ -46,11 +45,11 @@ type ResourcePatcher interface { Removed(attributePath []string) error } -func (req *OperationRequest) Validate() error { - if !slices.Contains(req.Schemas, schemas.IdPatchOperation) { - return serrors.ThrowInvalidSyntax(zerrors.ThrowInvalidArgumentf(nil, "SCIM-xy1schema", "Expected schema %v is not provided", schemas.IdPatchOperation)) - } +func (req *OperationRequest) GetSchemas() []schemas.ScimSchemaType { + return req.Schemas +} +func (req *OperationRequest) Validate() error { for _, op := range req.Operations { if err := op.validate(); err != nil { return err diff --git a/internal/api/scim/resources/resource_handler.go b/internal/api/scim/resources/resource_handler.go index b7e8bd04ec..027fd391dd 100644 --- a/internal/api/scim/resources/resource_handler.go +++ b/internal/api/scim/resources/resource_handler.go @@ -14,9 +14,9 @@ import ( ) type ResourceHandler[T ResourceHolder] interface { + SchemaType() schemas.ScimSchemaType ResourceNameSingular() schemas.ScimResourceTypeSingular ResourceNamePlural() schemas.ScimResourceTypePlural - SchemaType() schemas.ScimSchemaType NewResource() T Create(ctx context.Context, resource T) (T, error) @@ -28,6 +28,7 @@ type ResourceHandler[T ResourceHolder] interface { } type Resource struct { + ID string `json:"-"` Schemas []schemas.ScimSchemaType `json:"schemas"` Meta *ResourceMeta `json:"meta"` } @@ -41,9 +42,14 @@ type ResourceMeta struct { } type ResourceHolder interface { + SchemasHolder GetResource() *Resource } +type SchemasHolder interface { + GetSchemas() []schemas.ScimSchemaType +} + func buildResource[T ResourceHolder](ctx context.Context, handler ResourceHandler[T], details *domain.ObjectDetails) *Resource { created := details.CreationDate.UTC() if created.IsZero() { @@ -51,17 +57,18 @@ func buildResource[T ResourceHolder](ctx context.Context, handler ResourceHandle } return &Resource{ + ID: details.ID, Schemas: []schemas.ScimSchemaType{handler.SchemaType()}, Meta: &ResourceMeta{ ResourceType: handler.ResourceNameSingular(), Created: created, LastModified: details.EventDate.UTC(), 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 { - return http.DomainContext(ctx).Origin() + path.Join(schemas.HandlerPrefix, authz.GetCtxData(ctx).OrgID, string(handler.ResourceNamePlural()), id) +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(resourceName), id) } diff --git a/internal/api/scim/resources/resource_handler_adapter.go b/internal/api/scim/resources/resource_handler_adapter.go index 07a32fa1a1..2c528eeee5 100644 --- a/internal/api/scim/resources/resource_handler_adapter.go +++ b/internal/api/scim/resources/resource_handler_adapter.go @@ -1,17 +1,32 @@ package resources import ( + "context" "encoding/json" + "errors" + "io" "net/http" "slices" "github.com/gorilla/mux" + "github.com/zitadel/logging" "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/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 { 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) { - entity, err := adapter.readEntityFromBody(r) +func (adapter *ResourceHandlerAdapter[T]) ResourceNamePlural() schemas.ScimResourceTypePlural { + 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 { 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) { - entity, err := adapter.readEntityFromBody(r) +func (adapter *ResourceHandlerAdapter[T]) ReplaceFromHttp(r *http.Request) (ResourceHolder, error) { + 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 { return entity, err } - id := mux.Vars(r)["id"] - return adapter.handler.Replace(r.Context(), id, entity) + return adapter.handler.Replace(ctx, resourceID, 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) - err := json.NewDecoder(r.Body).Decode(request) - if err != nil { - if zerrors.IsZitadelError(err) { - return err - } - - return serrors.ThrowInvalidSyntax(zerrors.ThrowInvalidArgumentf(nil, "SCIM-ucrjson2", "Could not deserialize json: %v", err.Error())) + if err := readSchema(data, request, schemas.IdPatchOperation); err != nil { + return err } - err = request.Validate() - if err != nil { + if err := request.Validate(); err != nil { return err } @@ -61,17 +85,19 @@ func (adapter *ResourceHandlerAdapter[T]) Update(r *http.Request) error { return nil } - id := mux.Vars(r)["id"] - return adapter.handler.Update(r.Context(), id, request.Operations) + return adapter.handler.Update(ctx, resourceID, request.Operations) } -func (adapter *ResourceHandlerAdapter[T]) Delete(r *http.Request) error { - id := mux.Vars(r)["id"] - return adapter.handler.Delete(r.Context(), id) +func (adapter *ResourceHandlerAdapter[T]) DeleteFromHttp(r *http.Request) error { + return adapter.Delete(r.Context(), mux.Vars(r)["id"]) } -func (adapter *ResourceHandlerAdapter[T]) List(r *http.Request) (*ListResponse[T], error) { - request, err := readListRequest(r) +func (adapter *ResourceHandlerAdapter[T]) Delete(ctx context.Context, resourceID string) error { + 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 { return nil, err } @@ -79,30 +105,40 @@ func (adapter *ResourceHandlerAdapter[T]) List(r *http.Request) (*ListResponse[T 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"] 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() - 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 serrors.IsScimOrZitadelError(err) { - return entity, err + var maxBytesErr *http.MaxBytesError + 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() - if resource == nil { - return entity, serrors.ThrowInvalidSyntax(zerrors.ThrowInvalidArgument(nil, "SCIM-xxrjson", "Could not get resource, is the schema correct?")) + providedSchemas := entity.GetSchemas() + if !slices.Contains(providedSchemas, schema) { + return serrors.ThrowInvalidSyntax(zerrors.ThrowInvalidArgumentf(nil, "SCIM-xxrschema", "Expected schema %v is not provided", schema)) } - if !slices.Contains(resource.Schemas, adapter.handler.SchemaType()) { - return entity, serrors.ThrowInvalidSyntax(zerrors.ThrowInvalidArgumentf(nil, "SCIM-xxrschema", "Expected schema %v is not provided", adapter.handler.SchemaType())) - } - - return entity, nil + return nil } diff --git a/internal/api/scim/resources/resource_list.go b/internal/api/scim/resources/resource_list.go index ebd648d7ab..4bc5f00c3a 100644 --- a/internal/api/scim/resources/resource_list.go +++ b/internal/api/scim/resources/resource_list.go @@ -1,7 +1,6 @@ package resources import ( - "encoding/json" "net/http" zhttp "github.com/zitadel/zitadel/internal/api/http" @@ -13,6 +12,8 @@ import ( ) type ListRequest struct { + Schemas []schemas.ScimSchemaType `json:"schemas"` + // Count An integer indicating the desired maximum number of query results per page. Count int64 `json:"count" schema:"count"` @@ -47,6 +48,10 @@ const ( var parser = zhttp.NewParser() +func (r *ListRequest) GetSchemas() []schemas.ScimSchemaType { + return r.Schemas +} + func (o ListRequestSortOrder) isDefined() bool { switch o { 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{ Count: defaultListCount, 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()) } case http.MethodPost: - if err := json.NewDecoder(r.Body).Decode(request); err != nil { - if serrors.IsScimOrZitadelError(err) { - return nil, err - } - - return nil, zerrors.ThrowInvalidArgument(nil, "SCIM-ulljson", "Could not decode json: "+err.Error()) + if err := readSchema(r.Body, request, schemas.IdSearchRequest); err != nil { + return nil, err } // json deserialization initializes this field if an empty string is provided diff --git a/internal/api/scim/resources/user.go b/internal/api/scim/resources/user.go index b56e161653..3f2c274147 100644 --- a/internal/api/scim/resources/user.go +++ b/internal/api/scim/resources/user.go @@ -124,6 +124,13 @@ func (h *UsersHandler) ResourceNamePlural() scim_schemas.ScimResourceTypePlural func (u *ScimUser) GetResource() *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 { return new(ScimUser) diff --git a/internal/api/scim/resources/user_mapping.go b/internal/api/scim/resources/user_mapping.go index 75d547b888..16126dfded 100644 --- a/internal/api/scim/resources/user_mapping.go +++ b/internal/api/scim/resources/user_mapping.go @@ -384,13 +384,14 @@ func (h *UsersHandler) mapAndValidateMetadata(ctx context.Context, user *ScimUse func (h *UsersHandler) buildResourceForQuery(ctx context.Context, user *query.User) *Resource { return &Resource{ + ID: user.ID, Schemas: []schemas.ScimSchemaType{schemas.IdUser}, Meta: &ResourceMeta{ ResourceType: schemas.UserResourceType, Created: user.CreationDate.UTC(), LastModified: user.ChangeDate.UTC(), 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(), LastModified: user.ChangeDate.UTC(), Version: strconv.FormatUint(user.ProcessedSequence, 10), - Location: buildLocation(ctx, h, user.AggregateID), + Location: buildLocation(ctx, h.ResourceNamePlural(), user.AggregateID), }, } } diff --git a/internal/api/scim/schemas/schemas.go b/internal/api/scim/schemas/schemas.go index f940657d21..fc3eef8a2a 100644 --- a/internal/api/scim/schemas/schemas.go +++ b/internal/api/scim/schemas/schemas.go @@ -12,6 +12,9 @@ const ( IdUser ScimSchemaType = idPrefixCore + "User" IdListResponse ScimSchemaType = idPrefixMessages + "ListResponse" IdPatchOperation ScimSchemaType = idPrefixMessages + "PatchOp" + IdSearchRequest ScimSchemaType = idPrefixMessages + "SearchRequest" + IdBulkRequest ScimSchemaType = idPrefixMessages + "BulkRequest" + IdBulkResponse ScimSchemaType = idPrefixMessages + "BulkResponse" IdError ScimSchemaType = idPrefixMessages + "Error" IdZitadelErrorDetail ScimSchemaType = idPrefixZitadelMessages + "ErrorDetail" diff --git a/internal/api/scim/serrors/errors.go b/internal/api/scim/serrors/errors.go index a56d88503c..c6d6f806f4 100644 --- a/internal/api/scim/serrors/errors.go +++ b/internal/api/scim/serrors/errors.go @@ -23,18 +23,19 @@ type scimErrorType string type wrappedScimError struct { Parent error ScimType scimErrorType + Status int } -type scimError struct { +type ScimError struct { Schemas []schemas.ScimSchemaType `json:"schemas"` ScimType scimErrorType `json:"scimType,omitempty"` Detail string `json:"detail,omitempty"` StatusCode int `json:"-"` 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"` Message string `json:"message"` } @@ -78,7 +79,7 @@ func ErrorHandler(next zhttp_middleware.HandlerFuncWithError) http.Handler { return } - scimErr := mapToScimJsonError(r.Context(), err) + scimErr := MapToScimError(r.Context(), err) w.WriteHeader(scimErr.StatusCode) 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 { return IsScimError(err) || zerrors.IsZitadelError(err) } @@ -130,7 +138,7 @@ func IsScimError(err error) bool { 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) } @@ -138,17 +146,30 @@ func (err *wrappedScimError) Error() string { return fmt.Sprintf("SCIM Error: %s: %s", err.ScimType, err.Parent.Error()) } -func mapToScimJsonError(ctx context.Context, err error) *scimError { - scimErr := new(wrappedScimError) - if ok := errors.As(err, &scimErr); ok { - mappedErr := mapToScimJsonError(ctx, scimErr.Parent) - mappedErr.ScimType = scimErr.ScimType +func MapToScimError(ctx context.Context, err error) *ScimError { + scimError := new(ScimError) + if ok := errors.As(err, &scimError); ok { + return scimError + } + + 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 } zitadelErr := new(zerrors.ZitadelError) if ok := errors.As(err, &zitadelErr); !ok { - return &scimError{ + return &ScimError{ Schemas: []schemas.ScimSchemaType{schemas.IdError}, Detail: "Unknown internal server error", Status: strconv.Itoa(http.StatusInternalServerError), @@ -162,13 +183,13 @@ func mapToScimJsonError(ctx context.Context, err error) *scimError { } localizedMsg := translator.LocalizeFromCtx(ctx, zitadelErr.GetMessage(), nil) - return &scimError{ + return &ScimError{ Schemas: []schemas.ScimSchemaType{schemas.IdError, schemas.IdZitadelErrorDetail}, ScimType: mapErrorToScimErrorType(err), Detail: localizedMsg, StatusCode: statusCode, Status: strconv.Itoa(statusCode), - ZitadelDetail: &errorDetail{ + ZitadelDetail: &ErrorDetail{ ID: zitadelErr.GetID(), Message: zitadelErr.GetMessage(), }, diff --git a/internal/api/scim/server.go b/internal/api/scim/server.go index d589b480c4..5dd2acb60d 100644 --- a/internal/api/scim/server.go +++ b/internal/api/scim/server.go @@ -27,7 +27,8 @@ func NewServer( verifier *authz.ApiTokenVerifier, userCodeAlg crypto.EncryptionAlgorithm, config *sconfig.Config, - middlewares ...zhttp_middlware.MiddlewareWithErrorFunc) http.Handler { + middlewares ...zhttp_middlware.MiddlewareWithErrorFunc, +) http.Handler { verifier.RegisterServer("SCIM-V2", schemas.HandlerPrefix, AuthMapping) return buildHandler(command, query, userCodeAlg, config, middlewares...) } @@ -37,29 +38,41 @@ func buildHandler( query *query.Queries, userCodeAlg crypto.EncryptionAlgorithm, cfg *sconfig.Config, - middlewares ...zhttp_middlware.MiddlewareWithErrorFunc) http.Handler { + middlewares ...zhttp_middlware.MiddlewareWithErrorFunc, +) http.Handler { 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 middlewares = append([]zhttp_middlware.MiddlewareWithErrorFunc{smiddleware.ContentTypeMiddleware}, middlewares...) middlewares = append(middlewares, smiddleware.ScimContextMiddleware(query)) scimMiddleware := zhttp_middlware.ChainedWithErrorHandler(serrors.ErrorHandler, middlewares...) - mapResource(router, scimMiddleware, sresources.NewUsersHandler(command, query, userCodeAlg, cfg)) - return router + return func(handler zhttp_middlware.HandlerFuncWithError) http.Handler { + return http.MaxBytesHandler(scimMiddleware(handler), cfg.MaxRequestBodySize) + } } -func mapResource[T sresources.ResourceHolder](router *mux.Router, mw zhttp_middlware.ErrorHandlerFunc, handler sresources.ResourceHandler[T]) { - adapter := sresources.NewResourceHandlerAdapter[T](handler) - resourceRouter := router.PathPrefix("/" + path.Join(zhttp.OrgIdInPathVariable, string(handler.ResourceNamePlural()))).Subrouter() +func mapResource[T sresources.ResourceHolder](router *mux.Router, mw zhttp_middlware.ErrorHandlerFunc, adapter *sresources.ResourceHandlerAdapter[T]) { + resourceRouter := router.PathPrefix("/" + path.Join(zhttp.OrgIdInPathVariable, string(adapter.ResourceNamePlural()))).Subrouter() - resourceRouter.Handle("", mw(handleResourceCreatedResponse(adapter.Create))).Methods(http.MethodPost) - resourceRouter.Handle("", mw(handleJsonResponse(adapter.List))).Methods(http.MethodGet) - resourceRouter.Handle("/.search", mw(handleJsonResponse(adapter.List))).Methods(http.MethodPost) - resourceRouter.Handle("/{id}", mw(handleResourceResponse(adapter.Get))).Methods(http.MethodGet) - resourceRouter.Handle("/{id}", mw(handleResourceResponse(adapter.Replace))).Methods(http.MethodPut) - resourceRouter.Handle("/{id}", mw(handleEmptyResponse(adapter.Update))).Methods(http.MethodPatch) - resourceRouter.Handle("/{id}", mw(handleEmptyResponse(adapter.Delete))).Methods(http.MethodDelete) + resourceRouter.Handle("", mw(handleResourceCreatedResponse(adapter.CreateFromHttp))).Methods(http.MethodPost) + resourceRouter.Handle("", mw(handleJsonResponse(adapter.ListFromHttp))).Methods(http.MethodGet) + resourceRouter.Handle("/.search", mw(handleJsonResponse(adapter.ListFromHttp))).Methods(http.MethodPost) + resourceRouter.Handle("/{id}", mw(handleResourceResponse(adapter.GetFromHttp))).Methods(http.MethodGet) + resourceRouter.Handle("/{id}", mw(handleResourceResponse(adapter.ReplaceFromHttp))).Methods(http.MethodPut) + resourceRouter.Handle("/{id}", mw(handleEmptyResponse(adapter.UpdateFromHttp))).Methods(http.MethodPatch) + resourceRouter.Handle("/{id}", mw(handleEmptyResponse(adapter.DeleteFromHttp))).Methods(http.MethodDelete) } func handleJsonResponse[T any](next func(r *http.Request) (T, error)) zhttp_middlware.HandlerFuncWithError { diff --git a/internal/integration/scim/client.go b/internal/integration/scim/client.go index b51ce33d6f..d896c94794 100644 --- a/internal/integration/scim/client.go +++ b/internal/integration/scim/client.go @@ -21,12 +21,14 @@ import ( ) type Client struct { - Users *ResourceClient[resources.ScimUser] + client *http.Client + baseURL string + Users *ResourceClient[resources.ScimUser] } type ResourceClient[T any] struct { client *http.Client - baseUrl string + baseURL string resourceName string } @@ -44,6 +46,8 @@ type ZitadelErrorDetail struct { } type ListRequest struct { + Schemas []schemas.ScimSchemaType `json:"schemas"` + Count *int `json:"count,omitempty"` // 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"` } +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 ( listQueryParamSortBy = "sortBy" listQueryParamSortOrder = "sortOrder" @@ -85,14 +115,27 @@ func NewScimClient(target string) *Client { target = "http://" + target + schemas.HandlerPrefix client := &http.Client{} return &Client{ + client: client, + baseURL: target, Users: &ResourceClient[resources.ScimUser]{ client: client, - baseUrl: target, + baseURL: target, 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) { 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 { - 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 { 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) { + listResponse := new(ListResponse[*T]) + if req.SendAsPost { listReq, err := json.Marshal(req) if err != nil { 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("") @@ -144,7 +191,8 @@ func (c *ResourceClient[T]) List(ctx context.Context, orgID string, req *ListReq 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) { @@ -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 { - 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 { 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) { - req, err := http.NewRequestWithContext(ctx, method, c.buildURL(orgID, url), body) +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.buildResourceURL(orgID, url), body) if err != nil { - return nil, err + return err } req.Header.Set(zhttp.ContentType, middleware.ContentTypeScim) - response := new(ListResponse[*T]) - return response, c.doReq(req, response) + return doReq(c.client, req, response) } 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 { return nil, err } req.Header.Set(zhttp.ContentType, middleware.ContentTypeScim) 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) - resp, err := c.client.Do(req) + resp, err := client.Do(req) if err != nil { return err @@ -239,12 +286,12 @@ func readScimError(resp *http.Response) error { 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, "?") { - 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 {