diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index 1adbf0fa4e..3615c7fa34 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -580,6 +580,13 @@ SAML: # EmailAddress: hi@zitadel.com # ZITADEL_SAML_PROVIDERCONFIG_CONTACTPERSON_EMAILADDRESS SCIM: + DocumentationUrl: https://zitadel.com/docs/guides/manage/user/scim2 + AuthenticationSchemes: + - Name: Zitadel authentication token + Description: Authentication scheme using the OAuth Bearer Token Standard + SpecUri: https://www.rfc-editor.org/info/rfc6750 + DocumentationUri: https://zitadel.com/docs/guides/integrate/service-users/authenticate-service-users + Type: oauthbearertoken # 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 diff --git a/internal/api/scim/config/config.go b/internal/api/scim/config/config.go index 49c7d52a80..aa4f99e8c9 100644 --- a/internal/api/scim/config/config.go +++ b/internal/api/scim/config/config.go @@ -1,12 +1,23 @@ package config type Config struct { - EmailVerified bool - PhoneVerified bool - MaxRequestBodySize int64 - Bulk BulkConfig + DocumentationUrl string + AuthenticationSchemes []*ServiceProviderConfigAuthenticationScheme + EmailVerified bool + PhoneVerified bool + MaxRequestBodySize int64 + Bulk BulkConfig } type BulkConfig struct { MaxOperationsCount int } + +type ServiceProviderConfigAuthenticationScheme struct { + Name string `json:"name"` + Description string `json:"description"` + SpecUri string `json:"specUri"` + DocumentationUri string `json:"documentationUri"` + Type string `json:"type"` + Primary bool `json:"primary"` +} diff --git a/internal/api/scim/integration_test/service_provider_config_test.go b/internal/api/scim/integration_test/service_provider_config_test.go new file mode 100644 index 0000000000..6f575a809f --- /dev/null +++ b/internal/api/scim/integration_test/service_provider_config_test.go @@ -0,0 +1,133 @@ +//go:build integration + +package integration_test + +import ( + _ "embed" + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var ( + //go:embed testdata/service_provider_config_expected.json + expectedProviderConfigJson []byte + + //go:embed testdata/service_provider_config_expected_schemas.json + expectedSchemasJson []byte + + //go:embed testdata/service_provider_config_expected_resource_types.json + expectedResourceTypesJson []byte + + //go:embed testdata/service_provider_config_expected_resource_type_user.json + expectedResourceTypeUserJson []byte + + //go:embed testdata/service_provider_config_expected_user_schema.json + expectedUserSchemaJson []byte +) + +func TestServiceProviderConfig(t *testing.T) { + resp, err := Instance.Client.SCIM.GetServiceProviderConfig(CTX, Instance.DefaultOrg.Id) + assert.NoError(t, err) + assertJsonEqual(t, expectedProviderConfigJson, resp) +} + +func TestResourceTypes(t *testing.T) { + resp, err := Instance.Client.SCIM.GetResourceTypes(CTX, Instance.DefaultOrg.Id) + assert.NoError(t, err) + assertJsonEqual(t, expectedResourceTypesJson, resp) +} + +func TestResourceType(t *testing.T) { + tests := []struct { + name string + resourceName string + want []byte + wantErr bool + }{ + { + name: "user", + resourceName: "User", + want: expectedResourceTypeUserJson, + }, + { + name: "not found", + resourceName: "foobar", + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp, err := Instance.Client.SCIM.GetResourceType(CTX, Instance.DefaultOrg.Id, tt.resourceName) + if tt.wantErr { + require.Error(t, err) + return + } + + assert.NoError(t, err) + assertJsonEqual(t, tt.want, resp) + }) + } +} + +func TestSchemas(t *testing.T) { + resp, err := Instance.Client.SCIM.GetSchemas(CTX, Instance.DefaultOrg.Id) + assert.NoError(t, err) + assertJsonEqual(t, expectedSchemasJson, resp) +} + +func TestSchema(t *testing.T) { + tests := []struct { + name string + id string + want []byte + wantErr bool + }{ + { + name: "user", + id: "urn:ietf:params:scim:schemas:core:2.0:User", + want: expectedUserSchemaJson, + }, + { + name: "not found", + id: "foobar", + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp, err := Instance.Client.SCIM.GetSchema(CTX, Instance.DefaultOrg.Id, tt.id) + if tt.wantErr { + require.Error(t, err) + return + } + + assert.NoError(t, err) + assertJsonEqual(t, tt.want, resp) + }) + } +} + +func assertJsonEqual(t *testing.T, expected, actual []byte) { + t.Helper() + + // replace dynamic data json + expectedJson := strings.Replace(string(expected), "{domain}", Instance.Domain, 1) + expectedJson = strings.Replace(expectedJson, "{orgId}", Instance.DefaultOrg.Id, 1) + assert.Equal(t, normalizeJson(t, []byte(expectedJson)), normalizeJson(t, actual)) +} + +func normalizeJson(t *testing.T, content []byte) string { + t.Helper() + + raw := new(json.RawMessage) + err := json.Unmarshal(content, raw) + require.NoError(t, err) + + content, err = json.MarshalIndent(raw, "", " ") + require.NoError(t, err) + return string(content) // use string for easier assertion diffs +} diff --git a/internal/api/scim/integration_test/testdata/service_provider_config_expected.json b/internal/api/scim/integration_test/testdata/service_provider_config_expected.json new file mode 100644 index 0000000000..8f8d0f3a2b --- /dev/null +++ b/internal/api/scim/integration_test/testdata/service_provider_config_expected.json @@ -0,0 +1,41 @@ +{ + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig" + ], + "meta": { + "resourceType": "ServiceProviderConfig", + "location": "http://{domain}:8080/scim/v2/{orgId}/ServiceProviderConfig" + }, + "documentationUri": "https://zitadel.com/docs/guides/manage/user/scim2", + "patch": { + "supported": true + }, + "bulk": { + "supported": true, + "maxOperations": 100, + "maxPayloadSize": 1000000 + }, + "filter": { + "supported": true, + "maxResults": 100 + }, + "changePassword": { + "supported": true + }, + "sort": { + "supported": true + }, + "etag": { + "supported": false + }, + "authenticationSchemes": [ + { + "name": "Zitadel authentication token", + "description": "Authentication scheme using the OAuth Bearer Token Standard", + "specUri": "https://www.rfc-editor.org/info/rfc6750", + "documentationUri": "https://zitadel.com/docs/guides/integrate/service-users/authenticate-service-users", + "type": "oauthbearertoken", + "primary": false + } + ] +} \ No newline at end of file diff --git a/internal/api/scim/integration_test/testdata/service_provider_config_expected_resource_type_user.json b/internal/api/scim/integration_test/testdata/service_provider_config_expected_resource_type_user.json new file mode 100644 index 0000000000..df910ca9e9 --- /dev/null +++ b/internal/api/scim/integration_test/testdata/service_provider_config_expected_resource_type_user.json @@ -0,0 +1,14 @@ +{ + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:ResourceType" + ], + "meta": { + "resourceType": "User", + "location": "http://{domain}:8080/scim/v2/{orgId}/ResourceTypes/User" + }, + "id": "User", + "name": "User", + "endpoint": "Users", + "schema": "urn:ietf:params:scim:schemas:core:2.0:User", + "description": "User Account" +} \ No newline at end of file diff --git a/internal/api/scim/integration_test/testdata/service_provider_config_expected_resource_types.json b/internal/api/scim/integration_test/testdata/service_provider_config_expected_resource_types.json new file mode 100644 index 0000000000..ae79a93484 --- /dev/null +++ b/internal/api/scim/integration_test/testdata/service_provider_config_expected_resource_types.json @@ -0,0 +1,24 @@ +{ + "schemas": [ + "urn:ietf:params:scim:api:messages:2.0:ListResponse" + ], + "itemsPerPage": 100, + "totalResults": 1, + "startIndex": 1, + "Resources": [ + { + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:ResourceType" + ], + "meta": { + "resourceType": "User", + "location": "http://{domain}:8080/scim/v2/{orgId}/ResourceTypes/User" + }, + "id": "User", + "name": "User", + "endpoint": "Users", + "schema": "urn:ietf:params:scim:schemas:core:2.0:User", + "description": "User Account" + } + ] +} \ No newline at end of file diff --git a/internal/api/scim/integration_test/testdata/service_provider_config_expected_schemas.json b/internal/api/scim/integration_test/testdata/service_provider_config_expected_schemas.json new file mode 100644 index 0000000000..bc87b8e2e1 --- /dev/null +++ b/internal/api/scim/integration_test/testdata/service_provider_config_expected_schemas.json @@ -0,0 +1,601 @@ +{ + "schemas": [ + "urn:ietf:params:scim:api:messages:2.0:ListResponse" + ], + "itemsPerPage": 100, + "totalResults": 1, + "startIndex": 1, + "Resources": [ + { + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:Schema" + ], + "meta": { + "resourceType": "Schema", + "location": "http://{domain}:8080/scim/v2/{orgId}/Schemas/urn:ietf:params:scim:schemas:core:2.0:User" + }, + "id": "urn:ietf:params:scim:schemas:core:2.0:User", + "name": "User", + "description": "User Account", + "attributes": [ + { + "name": "externalId", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "userName", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": true, + "caseExact": false, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "server" + }, + { + "name": "name", + "description": "For details see RFC7643", + "type": "complex", + "subAttributes": [ + { + "name": "formatted", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "familyName", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": true, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "givenName", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": true, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "middleName", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "honorificPrefix", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "honorificSuffix", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + } + ], + "multiValued": false, + "required": true, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "displayName", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "nickName", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "profileUrl", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "title", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "preferredLanguage", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "locale", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "timezone", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "active", + "description": "For details see RFC7643", + "type": "boolean", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "emails", + "description": "For details see RFC7643", + "type": "complex", + "subAttributes": [ + { + "name": "value", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": true, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "primary", + "description": "For details see RFC7643", + "type": "boolean", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + } + ], + "multiValued": true, + "required": true, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "phoneNumbers", + "description": "For details see RFC7643", + "type": "complex", + "subAttributes": [ + { + "name": "value", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "primary", + "description": "For details see RFC7643", + "type": "boolean", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + } + ], + "multiValued": true, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "password", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "writeOnly", + "returned": "never", + "uniqueness": "none" + }, + { + "name": "ims", + "description": "For details see RFC7643", + "type": "complex", + "subAttributes": [ + { + "name": "value", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "type", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + } + ], + "multiValued": true, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "addresses", + "description": "For details see RFC7643", + "type": "complex", + "subAttributes": [ + { + "name": "type", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "streetAddress", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "locality", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "region", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "postalCode", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "country", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "formatted", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "primary", + "description": "For details see RFC7643", + "type": "boolean", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + } + ], + "multiValued": true, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "photos", + "description": "For details see RFC7643", + "type": "complex", + "subAttributes": [ + { + "name": "value", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "display", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "type", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "primary", + "description": "For details see RFC7643", + "type": "boolean", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + } + ], + "multiValued": true, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "entitlements", + "description": "For details see RFC7643", + "type": "complex", + "subAttributes": [ + { + "name": "value", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "display", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "type", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "primary", + "description": "For details see RFC7643", + "type": "boolean", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + } + ], + "multiValued": true, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "roles", + "description": "For details see RFC7643", + "type": "complex", + "subAttributes": [ + { + "name": "value", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "display", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "type", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "primary", + "description": "For details see RFC7643", + "type": "boolean", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + } + ], + "multiValued": true, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + } + ] + } + ] +} \ No newline at end of file diff --git a/internal/api/scim/integration_test/testdata/service_provider_config_expected_user_schema.json b/internal/api/scim/integration_test/testdata/service_provider_config_expected_user_schema.json new file mode 100644 index 0000000000..a199fe1465 --- /dev/null +++ b/internal/api/scim/integration_test/testdata/service_provider_config_expected_user_schema.json @@ -0,0 +1,591 @@ +{ + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:Schema" + ], + "meta": { + "resourceType": "Schema", + "location": "http://{domain}:8080/scim/v2/{orgId}/Schemas/urn:ietf:params:scim:schemas:core:2.0:User" + }, + "id": "urn:ietf:params:scim:schemas:core:2.0:User", + "name": "User", + "description": "User Account", + "attributes": [ + { + "name": "externalId", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "userName", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": true, + "caseExact": false, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "server" + }, + { + "name": "name", + "description": "For details see RFC7643", + "type": "complex", + "subAttributes": [ + { + "name": "formatted", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "familyName", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": true, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "givenName", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": true, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "middleName", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "honorificPrefix", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "honorificSuffix", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + } + ], + "multiValued": false, + "required": true, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "displayName", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "nickName", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "profileUrl", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "title", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "preferredLanguage", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "locale", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "timezone", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "active", + "description": "For details see RFC7643", + "type": "boolean", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "emails", + "description": "For details see RFC7643", + "type": "complex", + "subAttributes": [ + { + "name": "value", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": true, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "primary", + "description": "For details see RFC7643", + "type": "boolean", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + } + ], + "multiValued": true, + "required": true, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "phoneNumbers", + "description": "For details see RFC7643", + "type": "complex", + "subAttributes": [ + { + "name": "value", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "primary", + "description": "For details see RFC7643", + "type": "boolean", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + } + ], + "multiValued": true, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "password", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "writeOnly", + "returned": "never", + "uniqueness": "none" + }, + { + "name": "ims", + "description": "For details see RFC7643", + "type": "complex", + "subAttributes": [ + { + "name": "value", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "type", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + } + ], + "multiValued": true, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "addresses", + "description": "For details see RFC7643", + "type": "complex", + "subAttributes": [ + { + "name": "type", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "streetAddress", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "locality", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "region", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "postalCode", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "country", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "formatted", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "primary", + "description": "For details see RFC7643", + "type": "boolean", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + } + ], + "multiValued": true, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "photos", + "description": "For details see RFC7643", + "type": "complex", + "subAttributes": [ + { + "name": "value", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "display", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "type", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "primary", + "description": "For details see RFC7643", + "type": "boolean", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + } + ], + "multiValued": true, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "entitlements", + "description": "For details see RFC7643", + "type": "complex", + "subAttributes": [ + { + "name": "value", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "display", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "type", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "primary", + "description": "For details see RFC7643", + "type": "boolean", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + } + ], + "multiValued": true, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "roles", + "description": "For details see RFC7643", + "type": "complex", + "subAttributes": [ + { + "name": "value", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "display", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "type", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "primary", + "description": "For details see RFC7643", + "type": "boolean", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + } + ], + "multiValued": true, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" + } + ] +} \ No newline at end of file diff --git a/internal/api/scim/resources/bulk.go b/internal/api/scim/resources/bulk.go index 6aaddeec2f..5f5cd20fe7 100644 --- a/internal/api/scim/resources/bulk.go +++ b/internal/api/scim/resources/bulk.go @@ -60,7 +60,7 @@ func NewBulkHandler( ) *BulkHandler { handlersByPluralResourceName := make(map[schemas.ScimResourceTypePlural]RawResourceHandlerAdapter, len(handlers)) for _, handler := range handlers { - handlersByPluralResourceName[handler.ResourceNamePlural()] = handler + handlersByPluralResourceName[handler.Schema().PluralName] = handler } return &BulkHandler{ @@ -135,7 +135,7 @@ func (h *BulkHandler) processOperation(ctx context.Context, op *BulkRequestOpera } if resourceNamePlural != "" && resourceID != "" { - opResp.Location = buildLocation(ctx, resourceNamePlural, resourceID) + opResp.Location = schemas.BuildLocationForResource(ctx, resourceNamePlural, resourceID) } opResp.Status = strconv.Itoa(statusCode) diff --git a/internal/api/scim/resources/resource_handler.go b/internal/api/scim/resources/resource_handler.go index 027fd391dd..a1f6dc37e6 100644 --- a/internal/api/scim/resources/resource_handler.go +++ b/internal/api/scim/resources/resource_handler.go @@ -2,21 +2,17 @@ package resources import ( "context" - "path" "strconv" - "time" - "github.com/zitadel/zitadel/internal/api/authz" - "github.com/zitadel/zitadel/internal/api/http" + "github.com/muhlemmer/gu" + "github.com/zitadel/zitadel/internal/api/scim/resources/patch" "github.com/zitadel/zitadel/internal/api/scim/schemas" "github.com/zitadel/zitadel/internal/domain" ) type ResourceHandler[T ResourceHolder] interface { - SchemaType() schemas.ScimSchemaType - ResourceNameSingular() schemas.ScimResourceTypeSingular - ResourceNamePlural() schemas.ScimResourceTypePlural + Schema() *schemas.ResourceSchema NewResource() T Create(ctx context.Context, resource T) (T, error) @@ -27,48 +23,31 @@ type ResourceHandler[T ResourceHolder] interface { List(ctx context.Context, request *ListRequest) (*ListResponse[T], error) } -type Resource struct { - ID string `json:"-"` - Schemas []schemas.ScimSchemaType `json:"schemas"` - Meta *ResourceMeta `json:"meta"` -} - -type ResourceMeta struct { - ResourceType schemas.ScimResourceTypeSingular `json:"resourceType"` - Created time.Time `json:"created"` - LastModified time.Time `json:"lastModified"` - Version string `json:"version"` - Location string `json:"location"` -} - type ResourceHolder interface { SchemasHolder - GetResource() *Resource + GetResource() *schemas.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) *schemas.Resource { created := details.CreationDate.UTC() if created.IsZero() { created = details.EventDate.UTC() } - return &Resource{ + schema := handler.Schema() + return &schemas.Resource{ ID: details.ID, - Schemas: []schemas.ScimSchemaType{handler.SchemaType()}, - Meta: &ResourceMeta{ - ResourceType: handler.ResourceNameSingular(), - Created: created, - LastModified: details.EventDate.UTC(), + Schemas: []schemas.ScimSchemaType{schema.ID}, + Meta: &schemas.ResourceMeta{ + ResourceType: schema.Name, + Created: &created, + LastModified: gu.Ptr(details.EventDate.UTC()), Version: strconv.FormatUint(details.Sequence, 10), - Location: buildLocation(ctx, handler.ResourceNamePlural(), details.ID), + Location: schemas.BuildLocationForResource(ctx, schema.PluralName, details.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 2c528eeee5..b429934680 100644 --- a/internal/api/scim/resources/resource_handler_adapter.go +++ b/internal/api/scim/resources/resource_handler_adapter.go @@ -19,7 +19,7 @@ import ( // RawResourceHandlerAdapter adapts the ResourceHandler[T] without any generics type RawResourceHandlerAdapter interface { - ResourceNamePlural() schemas.ScimResourceTypePlural + Schema() *schemas.ResourceSchema Create(ctx context.Context, data io.ReadCloser) (ResourceHolder, error) Replace(ctx context.Context, resourceID string, data io.ReadCloser) (ResourceHolder, error) @@ -37,8 +37,8 @@ func NewResourceHandlerAdapter[T ResourceHolder](handler ResourceHandler[T]) *Re } } -func (adapter *ResourceHandlerAdapter[T]) ResourceNamePlural() schemas.ScimResourceTypePlural { - return adapter.handler.ResourceNamePlural() +func (adapter *ResourceHandlerAdapter[T]) Schema() *schemas.ResourceSchema { + return adapter.handler.Schema() } func (adapter *ResourceHandlerAdapter[T]) CreateFromHttp(r *http.Request) (ResourceHolder, error) { @@ -112,7 +112,7 @@ func (adapter *ResourceHandlerAdapter[T]) GetFromHttp(r *http.Request) (T, error func (adapter *ResourceHandlerAdapter[T]) readEntity(data io.ReadCloser) (T, error) { entity := adapter.handler.NewResource() - return entity, readSchema(data, entity, adapter.handler.SchemaType()) + return entity, readSchema(data, entity, adapter.handler.Schema().ID) } func readSchema(data io.ReadCloser, entity SchemasHolder, schema schemas.ScimSchemaType) error { diff --git a/internal/api/scim/resources/resource_list.go b/internal/api/scim/resources/resource_list.go index 4bc5f00c3a..e9996383d9 100644 --- a/internal/api/scim/resources/resource_list.go +++ b/internal/api/scim/resources/resource_list.go @@ -28,7 +28,7 @@ type ListRequest struct { SortOrder ListRequestSortOrder `json:"sortOrder" schema:"sortOrder"` } -type ListResponse[T ResourceHolder] struct { +type ListResponse[T any] struct { Schemas []schemas.ScimSchemaType `json:"schemas"` ItemsPerPage uint64 `json:"itemsPerPage"` TotalResults uint64 `json:"totalResults"` @@ -43,7 +43,7 @@ const ( ListRequestSortOrderDsc ListRequestSortOrder = "descending" defaultListCount = 100 - maxListCount = 100 + MaxListCount = 100 ) var parser = zhttp.NewParser() @@ -65,7 +65,7 @@ func (o ListRequestSortOrder) IsAscending() bool { return o == ListRequestSortOrderAsc } -func newListResponse[T ResourceHolder](totalResultCount uint64, q query.SearchRequest, resources []T) *ListResponse[T] { +func NewListResponse[T any](totalResultCount uint64, q query.SearchRequest, resources []T) *ListResponse[T] { return &ListResponse[T]{ Schemas: []schemas.ScimSchemaType{schemas.IdListResponse}, ItemsPerPage: q.Limit, @@ -137,8 +137,8 @@ func (r *ListRequest) validate() error { // according to the spec values < 0 are treated as 0 if r.Count < 0 { r.Count = 0 - } else if r.Count > maxListCount { - return zerrors.ThrowInvalidArgumentf(nil, "SCIM-ucr", "Limit count exceeded, set a count <= %v", maxListCount) + } else if r.Count > MaxListCount { + return zerrors.ThrowInvalidArgumentf(nil, "SCIM-ucr", "Limit count exceeded, set a count <= %v", MaxListCount) } if !r.SortOrder.isDefined() { diff --git a/internal/api/scim/resources/user.go b/internal/api/scim/resources/user.go index 3f2c274147..f2a8d9cfbe 100644 --- a/internal/api/scim/resources/user.go +++ b/internal/api/scim/resources/user.go @@ -23,30 +23,31 @@ type UsersHandler struct { userCodeAlg crypto.EncryptionAlgorithm config *scim_config.Config filterEvaluator *filter.Evaluator + schema *scim_schemas.ResourceSchema } type ScimUser struct { - *Resource - ID string `json:"id"` - ExternalID string `json:"externalId,omitempty"` - UserName string `json:"userName,omitempty"` - Name *ScimUserName `json:"name,omitempty"` - DisplayName string `json:"displayName,omitempty"` - NickName string `json:"nickName,omitempty"` - ProfileUrl *scim_schemas.HttpURL `json:"profileUrl,omitempty"` - Title string `json:"title,omitempty"` - PreferredLanguage language.Tag `json:"preferredLanguage,omitempty"` - Locale string `json:"locale,omitempty"` - Timezone string `json:"timezone,omitempty"` - Active *bool `json:"active,omitempty"` - Emails []*ScimEmail `json:"emails,omitempty"` - PhoneNumbers []*ScimPhoneNumber `json:"phoneNumbers,omitempty"` - Password *scim_schemas.WriteOnlyString `json:"password,omitempty"` - Ims []*ScimIms `json:"ims,omitempty"` - Addresses []*ScimAddress `json:"addresses,omitempty"` - Photos []*ScimPhoto `json:"photos,omitempty"` - Entitlements []*ScimEntitlement `json:"entitlements,omitempty"` - Roles []*ScimRole `json:"roles,omitempty"` + *scim_schemas.Resource `scim:"ignoreInSchema"` + ID string `json:"id" scim:"ignoreInSchema"` + ExternalID string `json:"externalId,omitempty"` + UserName string `json:"userName,omitempty" scim:"required,unique,caseInsensitive"` + Name *ScimUserName `json:"name,omitempty" scim:"required"` + DisplayName string `json:"displayName,omitempty"` + NickName string `json:"nickName,omitempty"` + ProfileUrl *scim_schemas.HttpURL `json:"profileUrl,omitempty"` + Title string `json:"title,omitempty"` + PreferredLanguage language.Tag `json:"preferredLanguage,omitempty"` + Locale string `json:"locale,omitempty"` + Timezone string `json:"timezone,omitempty"` + Active *bool `json:"active,omitempty"` + Emails []*ScimEmail `json:"emails,omitempty" scim:"required"` + PhoneNumbers []*ScimPhoneNumber `json:"phoneNumbers,omitempty"` + Password *scim_schemas.WriteOnlyString `json:"password,omitempty"` + Ims []*ScimIms `json:"ims,omitempty"` + Addresses []*ScimAddress `json:"addresses,omitempty"` + Photos []*ScimPhoto `json:"photos,omitempty"` + Entitlements []*ScimEntitlement `json:"entitlements,omitempty"` + Roles []*ScimRole `json:"roles,omitempty"` } type ScimEntitlement struct { @@ -87,7 +88,7 @@ type ScimIms struct { } type ScimEmail struct { - Value string `json:"value"` + Value string `json:"value" scim:"required"` Primary bool `json:"primary"` } @@ -98,8 +99,8 @@ type ScimPhoneNumber struct { type ScimUserName struct { Formatted string `json:"formatted,omitempty"` - FamilyName string `json:"familyName,omitempty"` - GivenName string `json:"givenName,omitempty"` + FamilyName string `json:"familyName,omitempty" scim:"required"` + GivenName string `json:"givenName,omitempty" scim:"required"` MiddleName string `json:"middleName,omitempty"` HonorificPrefix string `json:"honorificPrefix,omitempty"` HonorificSuffix string `json:"honorificSuffix,omitempty"` @@ -110,20 +111,26 @@ func NewUsersHandler( query *query.Queries, userCodeAlg crypto.EncryptionAlgorithm, config *scim_config.Config) ResourceHandler[*ScimUser] { - return &UsersHandler{command, query, userCodeAlg, config, filter.NewEvaluator(scim_schemas.IdUser)} + return &UsersHandler{ + command, + query, + userCodeAlg, + config, + filter.NewEvaluator(scim_schemas.IdUser), + scim_schemas.BuildSchema(scim_schemas.SchemaBuilderArgs{ + ID: scim_schemas.IdUser, + Name: scim_schemas.UserResourceType, + EndpointName: scim_schemas.UsersResourceType, + Description: "User Account", + Resource: new(ScimUser), + }), + } } -func (h *UsersHandler) ResourceNameSingular() scim_schemas.ScimResourceTypeSingular { - return scim_schemas.UserResourceType -} - -func (h *UsersHandler) ResourceNamePlural() scim_schemas.ScimResourceTypePlural { - return scim_schemas.UsersResourceType -} - -func (u *ScimUser) GetResource() *Resource { +func (u *ScimUser) GetResource() *scim_schemas.Resource { return u.Resource } + func (u *ScimUser) GetSchemas() []scim_schemas.ScimSchemaType { if u.Resource == nil { return nil @@ -132,12 +139,12 @@ func (u *ScimUser) GetSchemas() []scim_schemas.ScimSchemaType { return u.Resource.Schemas } -func (h *UsersHandler) NewResource() *ScimUser { - return new(ScimUser) +func (h *UsersHandler) Schema() *scim_schemas.ResourceSchema { + return h.schema } -func (h *UsersHandler) SchemaType() scim_schemas.ScimSchemaType { - return scim_schemas.IdUser +func (h *UsersHandler) NewResource() *ScimUser { + return new(ScimUser) } func (h *UsersHandler) Create(ctx context.Context, user *ScimUser) (*ScimUser, error) { @@ -226,7 +233,7 @@ func (h *UsersHandler) List(ctx context.Context, request *ListRequest) (*ListRes return nil, err } - return newListResponse(count, q.SearchRequest, make([]*ScimUser, 0)), nil + return NewListResponse(count, q.SearchRequest, make([]*ScimUser, 0)), nil } users, err := h.query.SearchUsers(ctx, q, nil) @@ -240,7 +247,7 @@ func (h *UsersHandler) List(ctx context.Context, request *ListRequest) (*ListRes } scimUsers := h.mapToScimUsers(ctx, users.Users, metadata) - return newListResponse(users.SearchResponse.Count, q.SearchRequest, scimUsers), nil + return NewListResponse(users.SearchResponse.Count, q.SearchRequest, scimUsers), nil } func (h *UsersHandler) queryUserDependencies(ctx context.Context, userID string) ([]*command.CascadingMembership, []string, error) { diff --git a/internal/api/scim/resources/user_mapping.go b/internal/api/scim/resources/user_mapping.go index 16126dfded..4fb0940fa6 100644 --- a/internal/api/scim/resources/user_mapping.go +++ b/internal/api/scim/resources/user_mapping.go @@ -382,29 +382,29 @@ func (h *UsersHandler) mapAndValidateMetadata(ctx context.Context, user *ScimUse } } -func (h *UsersHandler) buildResourceForQuery(ctx context.Context, user *query.User) *Resource { - return &Resource{ +func (h *UsersHandler) buildResourceForQuery(ctx context.Context, user *query.User) *schemas.Resource { + return &schemas.Resource{ ID: user.ID, Schemas: []schemas.ScimSchemaType{schemas.IdUser}, - Meta: &ResourceMeta{ + Meta: &schemas.ResourceMeta{ ResourceType: schemas.UserResourceType, - Created: user.CreationDate.UTC(), - LastModified: user.ChangeDate.UTC(), + Created: gu.Ptr(user.CreationDate.UTC()), + LastModified: gu.Ptr(user.ChangeDate.UTC()), Version: strconv.FormatUint(user.Sequence, 10), - Location: buildLocation(ctx, h.ResourceNamePlural(), user.ID), + Location: schemas.BuildLocationForResource(ctx, h.schema.PluralName, user.ID), }, } } -func (h *UsersHandler) buildResourceForWriteModel(ctx context.Context, user *command.UserV2WriteModel) *Resource { - return &Resource{ +func (h *UsersHandler) buildResourceForWriteModel(ctx context.Context, user *command.UserV2WriteModel) *schemas.Resource { + return &schemas.Resource{ Schemas: []schemas.ScimSchemaType{schemas.IdUser}, - Meta: &ResourceMeta{ + Meta: &schemas.ResourceMeta{ ResourceType: schemas.UserResourceType, - Created: user.CreationDate.UTC(), - LastModified: user.ChangeDate.UTC(), + Created: gu.Ptr(user.CreationDate.UTC()), + LastModified: gu.Ptr(user.ChangeDate.UTC()), Version: strconv.FormatUint(user.ProcessedSequence, 10), - Location: buildLocation(ctx, h.ResourceNamePlural(), user.AggregateID), + Location: schemas.BuildLocationForResource(ctx, h.schema.PluralName, user.AggregateID), }, } } diff --git a/internal/api/scim/resources/user_query_builder.go b/internal/api/scim/resources/user_query_builder.go index 15252f8d5f..b86b171fb5 100644 --- a/internal/api/scim/resources/user_query_builder.go +++ b/internal/api/scim/resources/user_query_builder.go @@ -88,7 +88,7 @@ func (h *UsersHandler) buildListQuery(ctx context.Context, request *ListRequest) return q, nil } - filterQuery, err := request.Filter.BuildQuery(ctx, h.SchemaType(), fieldPathColumnMapping) + filterQuery, err := request.Filter.BuildQuery(ctx, h.schema.ID, fieldPathColumnMapping) if err != nil { return nil, err } diff --git a/internal/api/scim/schemas/schema_builder.go b/internal/api/scim/schemas/schema_builder.go new file mode 100644 index 0000000000..96066e5575 --- /dev/null +++ b/internal/api/scim/schemas/schema_builder.go @@ -0,0 +1,183 @@ +package schemas + +import ( + "fmt" + "reflect" + "slices" + "strings" + "time" + + "golang.org/x/text/language" +) + +type SchemaBuilderArgs struct { + ID ScimSchemaType + Name ScimResourceTypeSingular + EndpointName ScimResourceTypePlural + Description string + Resource any +} + +type fieldSchemaInfo struct { + Ignore bool + Required bool + CaseExact bool + Unique bool +} + +var ( + timeType = reflect.TypeOf(time.Time{}) + languageTagType = reflect.TypeOf(language.Tag{}) + httpURLType = reflect.TypeOf(HttpURL{}) + writeOnlyStringType = reflect.TypeOf(WriteOnlyString("")) +) + +func BuildSchema(args SchemaBuilderArgs) *ResourceSchema { + return &ResourceSchema{ + Resource: &Resource{ + Schemas: []ScimSchemaType{IdSchema}, + ID: string(args.ID), + Meta: &ResourceMeta{ + ResourceType: SchemaResourceType, + }, + }, + ID: args.ID, + Name: args.Name, + PluralName: args.EndpointName, + Description: args.Description, + Attributes: buildSchemaAttributes(reflect.TypeOf(args.Resource)), + } +} + +func buildSchemaAttributes(fieldType reflect.Type) []*SchemaAttribute { + if fieldType.Kind() == reflect.Ptr { + fieldType = fieldType.Elem() + } + + if fieldType.Kind() != reflect.Struct { + return nil + } + + attributes := make([]*SchemaAttribute, 0, fieldType.NumField()) + for i := 0; i < fieldType.NumField(); i++ { + field := fieldType.Field(i) + attribute := buildAttribute(field) + + if attribute != nil { + attributes = append(attributes, attribute) + } + } + + return attributes +} + +func buildAttribute(field reflect.StructField) *SchemaAttribute { + info := getFieldSchemaInfo(field) + if info.Ignore { + return nil + } + + fieldType := getFieldType(field) + attribute := &SchemaAttribute{ + Name: getFieldJsonName(field), + Description: "For details see RFC7643", + Type: getFieldAttributeType(fieldType), + MultiValued: isFieldMultiValued(field), + Required: info.Required, + CaseExact: info.CaseExact, + Mutability: SchemaAttributeMutabilityReadWrite, + Returned: SchemaAttributeReturnedAlways, + Uniqueness: SchemaAttributeUniquenessNone, + } + + if attribute.Type == SchemaAttributeTypeComplex { + attribute.SubAttributes = buildSchemaAttributes(fieldType) + } + + if fieldType == writeOnlyStringType { + attribute.Returned = SchemaAttributeReturnedNever + attribute.Mutability = SchemaAttributeMutabilityWriteOnly + } + + if info.Unique { + attribute.Uniqueness = SchemaAttributeUniquenessServer + } + + return attribute +} + +func isFieldMultiValued(field reflect.StructField) bool { + if field.Type.Kind() != reflect.Ptr { + return field.Type.Kind() == reflect.Slice + } + + return field.Type.Elem().Kind() == reflect.Slice +} + +func getFieldAttributeType(fieldType reflect.Type) SchemaAttributeType { + switch fieldType.Kind() { //nolint:exhaustive + case reflect.String: + return SchemaAttributeTypeString + case reflect.Bool: + return SchemaAttributeTypeBoolean + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return SchemaAttributeTypeInteger + case reflect.Float32, reflect.Float64: + return SchemaAttributeTypeDecimal + case reflect.Struct: + switch fieldType { + case timeType: + return SchemaAttributeTypeDateTime + case writeOnlyStringType, languageTagType, httpURLType: + return SchemaAttributeTypeString + default: + return SchemaAttributeTypeComplex + } + default: + panic(fmt.Sprintf("unsupported field type: %v", fieldType.Kind())) + } +} + +func getFieldType(field reflect.StructField) reflect.Type { + fieldType := field.Type + if fieldType.Kind() == reflect.Ptr { + fieldType = fieldType.Elem() + } + + if fieldType.Kind() == reflect.Slice || fieldType.Kind() == reflect.Array { + fieldType = fieldType.Elem() + + if fieldType.Kind() == reflect.Ptr { + fieldType = fieldType.Elem() + } + } + return fieldType +} + +func getFieldSchemaInfo(field reflect.StructField) *fieldSchemaInfo { + tag := field.Tag.Get("scim") + tagOptions := strings.Split(tag, ",") + return &fieldSchemaInfo{ + Ignore: slices.Contains(tagOptions, "ignoreInSchema"), + Required: slices.Contains(tagOptions, "required"), + CaseExact: !slices.Contains(tagOptions, "caseInsensitive"), + Unique: slices.Contains(tagOptions, "unique"), + } +} + +func getFieldJsonName(field reflect.StructField) string { + jsonTag := field.Tag.Get("json") + + // Skip fields explicitly excluded + if jsonTag == "-" { + return "" + } + + // use field name as default + if jsonTag == "" { + return field.Name + } + + // strip other options such as omitempty + return strings.Split(jsonTag, ",")[0] +} diff --git a/internal/api/scim/schemas/schemas.go b/internal/api/scim/schemas/schemas.go index fc3eef8a2a..cdf2950bbe 100644 --- a/internal/api/scim/schemas/schemas.go +++ b/internal/api/scim/schemas/schemas.go @@ -1,5 +1,14 @@ package schemas +import ( + "context" + "path" + "time" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/api/http" +) + type ScimSchemaType string type ScimResourceTypeSingular string type ScimResourceTypePlural string @@ -9,17 +18,147 @@ const ( idPrefixCore = "urn:ietf:params:scim:schemas:core:2.0:" idPrefixZitadelMessages = "urn:ietf:params:scim:api:zitadel:messages:2.0:" - 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" + IdUser ScimSchemaType = idPrefixCore + "User" + IdServiceProviderConfig ScimSchemaType = idPrefixCore + "ServiceProviderConfig" + IdResourceType ScimSchemaType = idPrefixCore + "ResourceType" + IdSchema ScimSchemaType = idPrefixCore + "Schema" + 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" UserResourceType ScimResourceTypeSingular = "User" UsersResourceType ScimResourceTypePlural = "Users" + ServiceProviderConfigResourceType ScimResourceTypeSingular = "ServiceProviderConfig" + ServiceProviderConfigsResourceType ScimResourceTypePlural = "ServiceProviderConfig" + + SchemaResourceType ScimResourceTypeSingular = "Schema" + SchemasResourceType ScimResourceTypePlural = "Schemas" + + ResourceTypesResourceType ScimResourceTypePlural = "ResourceTypes" + HandlerPrefix = "/scim/v2" ) + +type Resource struct { + ID string `json:"-"` + Schemas []ScimSchemaType `json:"schemas"` + Meta *ResourceMeta `json:"meta"` +} + +type ResourceMeta struct { + ResourceType ScimResourceTypeSingular `json:"resourceType"` + Created *time.Time `json:"created,omitempty"` + LastModified *time.Time `json:"lastModified,omitempty"` + Version string `json:"version,omitempty"` + Location string `json:"location,omitempty"` +} + +type ResourceType struct { + *Resource + ID ScimResourceTypeSingular `json:"id"` + Name ScimResourceTypeSingular `json:"name"` + Endpoint ScimResourceTypePlural `json:"endpoint"` + Schema ScimSchemaType `json:"schema"` + Description string `json:"description"` +} + +type ResourceSchema struct { + *Resource + ID ScimSchemaType `json:"id"` + Name ScimResourceTypeSingular `json:"name"` + PluralName ScimResourceTypePlural `json:"-"` + Description string `json:"description,omitempty"` + Attributes []*SchemaAttribute `json:"attributes"` +} + +type SchemaAttribute struct { + Name string `json:"name"` + Description string `json:"description"` + Type SchemaAttributeType `json:"type"` + SubAttributes []*SchemaAttribute `json:"subAttributes,omitempty"` + MultiValued bool `json:"multiValued"` + Required bool `json:"required"` + CaseExact bool `json:"caseExact"` + Mutability SchemaAttributeMutability `json:"mutability"` + Returned SchemaAttributeReturned `json:"returned"` + Uniqueness SchemaAttributeUniqueness `json:"uniqueness"` +} + +type SchemaAttributeType string + +const ( + SchemaAttributeTypeString SchemaAttributeType = "string" + SchemaAttributeTypeBoolean SchemaAttributeType = "boolean" + SchemaAttributeTypeDecimal SchemaAttributeType = "decimal" + SchemaAttributeTypeInteger SchemaAttributeType = "integer" + SchemaAttributeTypeDateTime SchemaAttributeType = "dateTime" + SchemaAttributeTypeComplex SchemaAttributeType = "complex" +) + +type SchemaAttributeMutability string + +const ( + SchemaAttributeMutabilityReadWrite SchemaAttributeMutability = "readWrite" + SchemaAttributeMutabilityWriteOnly SchemaAttributeMutability = "writeOnly" +) + +type SchemaAttributeReturned string + +const ( + SchemaAttributeReturnedAlways SchemaAttributeReturned = "always" + SchemaAttributeReturnedNever SchemaAttributeReturned = "never" +) + +type SchemaAttributeUniqueness string + +const ( + SchemaAttributeUniquenessNone SchemaAttributeUniqueness = "none" + SchemaAttributeUniquenessServer SchemaAttributeUniqueness = "server" +) + +func (s *ResourceType) GetSchemas() []ScimSchemaType { + return s.Resource.Schemas +} + +func (s *ResourceType) GetResource() *Resource { + return s.Resource +} + +func (s *ResourceSchema) GetSchemas() []ScimSchemaType { + return s.Resource.Schemas +} + +func (s *ResourceSchema) GetResource() *Resource { + return s.Resource +} + +func (s *ResourceSchema) ToResourceType(ctx context.Context, orgID string) *ResourceType { + return &ResourceType{ + Resource: &Resource{ + Schemas: []ScimSchemaType{IdResourceType}, + ID: string(s.Name), + Meta: &ResourceMeta{ + ResourceType: s.Name, + Location: BuildLocationWithOrg(ctx, orgID, ResourceTypesResourceType, string(s.Name)), + }, + }, + ID: s.Name, + Name: s.Name, + Endpoint: s.PluralName, + Schema: s.ID, + Description: s.Description, + } +} + +func BuildLocationForResource(ctx context.Context, resourceName ScimResourceTypePlural, id string) string { + return BuildLocationWithOrg(ctx, authz.GetCtxData(ctx).OrgID, resourceName, id) +} + +func BuildLocationWithOrg(ctx context.Context, orgID string, resourceName ScimResourceTypePlural, id string) string { + return http.DomainContext(ctx).Origin() + path.Join(HandlerPrefix, orgID, string(resourceName), id) +} diff --git a/internal/api/scim/schemas/string.go b/internal/api/scim/schemas/string.go index b62e50893d..51a1d96baa 100644 --- a/internal/api/scim/schemas/string.go +++ b/internal/api/scim/schemas/string.go @@ -7,11 +7,6 @@ import "encoding/json" // This increases security to really ensure this is never sent to a client. type WriteOnlyString string -func NewWriteOnlyString(s string) *WriteOnlyString { - wos := WriteOnlyString(s) - return &wos -} - func (s *WriteOnlyString) MarshalJSON() ([]byte, error) { return []byte("null"), nil } diff --git a/internal/api/scim/server.go b/internal/api/scim/server.go index 5dd2acb60d..85b34da9f8 100644 --- a/internal/api/scim/server.go +++ b/internal/api/scim/server.go @@ -50,6 +50,13 @@ func buildHandler( bulkHandler := sresources.NewBulkHandler(cfg.Bulk, usersHandler) router.Handle("/"+zhttp.OrgIdInPathVariable+"/Bulk", middleware(handleJsonResponse(bulkHandler.BulkFromHttp))).Methods(http.MethodPost) + serviceProviderHandler := newServiceProviderHandler(cfg, usersHandler) + router.Handle("/"+zhttp.OrgIdInPathVariable+"/ServiceProviderConfig", middleware(handleJsonResponse(serviceProviderHandler.GetConfig))).Methods(http.MethodGet) + router.Handle("/"+zhttp.OrgIdInPathVariable+"/ResourceTypes", middleware(handleJsonResponse(serviceProviderHandler.ListResourceTypes))).Methods(http.MethodGet) + router.Handle("/"+zhttp.OrgIdInPathVariable+"/ResourceTypes/{name}", middleware(handleResourceResponse(serviceProviderHandler.GetResourceType))).Methods(http.MethodGet) + router.Handle("/"+zhttp.OrgIdInPathVariable+"/Schemas", middleware(handleJsonResponse(serviceProviderHandler.ListSchemas))).Methods(http.MethodGet) + router.Handle("/"+zhttp.OrgIdInPathVariable+"/Schemas/{id}", middleware(handleResourceResponse(serviceProviderHandler.GetSchema))).Methods(http.MethodGet) + return router } @@ -64,7 +71,7 @@ func buildMiddleware(cfg *sconfig.Config, query *query.Queries, middlewares []zh } 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 := router.PathPrefix("/" + path.Join(zhttp.OrgIdInPathVariable, string(adapter.Schema().PluralName))).Subrouter() resourceRouter.Handle("", mw(handleResourceCreatedResponse(adapter.CreateFromHttp))).Methods(http.MethodPost) resourceRouter.Handle("", mw(handleJsonResponse(adapter.ListFromHttp))).Methods(http.MethodGet) diff --git a/internal/api/scim/service_provider.go b/internal/api/scim/service_provider.go new file mode 100644 index 0000000000..25cd7a30a1 --- /dev/null +++ b/internal/api/scim/service_provider.go @@ -0,0 +1,182 @@ +package scim + +import ( + "context" + "net/http" + + "github.com/gorilla/mux" + + zhttp "github.com/zitadel/zitadel/internal/api/http" + scim_config "github.com/zitadel/zitadel/internal/api/scim/config" + sresources "github.com/zitadel/zitadel/internal/api/scim/resources" + sschemas "github.com/zitadel/zitadel/internal/api/scim/schemas" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/zerrors" +) + +type serviceProviderHandler struct { + config *scim_config.Config + schemas []*sschemas.ResourceSchema + schemasByID map[sschemas.ScimSchemaType]*sschemas.ResourceSchema + schemasByResourceName map[sschemas.ScimResourceTypeSingular]*sschemas.ResourceSchema +} + +type serviceProviderConfig struct { + *sschemas.Resource + DocumentationUri string `json:"documentationUri"` + Patch serviceProviderConfigSupported `json:"patch"` + Bulk serviceProviderConfigBulk `json:"bulk"` + Filter serviceProviderFilterSupported `json:"filter"` + ChangePassword serviceProviderConfigSupported `json:"changePassword"` + Sort serviceProviderConfigSupported `json:"sort"` + ETag serviceProviderConfigSupported `json:"etag"` + AuthenticationSchemes []*scim_config.ServiceProviderConfigAuthenticationScheme `json:"authenticationSchemes,omitempty"` +} + +type serviceProviderConfigSupported struct { + Supported bool `json:"supported"` +} + +type serviceProviderFilterSupported struct { + Supported bool `json:"supported"` + MaxResults int `json:"maxResults"` +} + +type serviceProviderConfigBulk struct { + Supported bool `json:"supported"` + MaxOperations int `json:"maxOperations"` + MaxPayloadSize int64 `json:"maxPayloadSize"` +} + +var ( + defaultConfigSearchRequest = query.SearchRequest{ + Offset: 0, + Limit: 100, + } +) + +func newServiceProviderHandler(cfg *scim_config.Config, handlers ...sresources.RawResourceHandlerAdapter) *serviceProviderHandler { + schemas := make([]*sschemas.ResourceSchema, len(handlers)) + schemasByID := make(map[sschemas.ScimSchemaType]*sschemas.ResourceSchema, len(handlers)) + schemasByResourceName := make(map[sschemas.ScimResourceTypeSingular]*sschemas.ResourceSchema, len(handlers)) + for i, handler := range handlers { + schema := handler.Schema() + schemas[i] = schema + schemasByID[schema.ID] = schema + schemasByResourceName[schema.Name] = schema + } + + return &serviceProviderHandler{ + config: cfg, + schemas: schemas, + schemasByID: schemasByID, + schemasByResourceName: schemasByResourceName, + } +} + +func (h *serviceProviderHandler) GetConfig(r *http.Request) (*serviceProviderConfig, error) { + // the request is unauthenticated, read the orgID from the url instead of the context + orgID := mux.Vars(r)[zhttp.OrgIdInPathVariableName] + return &serviceProviderConfig{ + Resource: &sschemas.Resource{ + Schemas: []sschemas.ScimSchemaType{sschemas.IdServiceProviderConfig}, + Meta: &sschemas.ResourceMeta{ + ResourceType: sschemas.ServiceProviderConfigResourceType, + Location: sschemas.BuildLocationWithOrg(r.Context(), orgID, sschemas.ServiceProviderConfigsResourceType, ""), + }, + }, + DocumentationUri: h.config.DocumentationUrl, + Patch: serviceProviderConfigSupported{ + Supported: true, + }, + Bulk: serviceProviderConfigBulk{ + Supported: true, + MaxOperations: h.config.Bulk.MaxOperationsCount, + MaxPayloadSize: h.config.MaxRequestBodySize, + }, + Filter: serviceProviderFilterSupported{ + Supported: true, + MaxResults: sresources.MaxListCount, + }, + ChangePassword: serviceProviderConfigSupported{ + Supported: true, + }, + Sort: serviceProviderConfigSupported{ + Supported: true, + }, + ETag: serviceProviderConfigSupported{ + Supported: false, + }, + AuthenticationSchemes: h.config.AuthenticationSchemes, + }, nil +} + +func (h *serviceProviderHandler) ListResourceTypes(r *http.Request) (*sresources.ListResponse[*sschemas.ResourceType], error) { + // the request is unauthenticated, read the orgID from the url instead of the context + ctx := r.Context() + orgID := mux.Vars(r)[zhttp.OrgIdInPathVariableName] + + resourceTypes := make([]*sschemas.ResourceType, len(h.schemas)) + for i, schema := range h.schemas { + resourceTypes[i] = schema.ToResourceType(ctx, orgID) + } + + return sresources.NewListResponse(uint64(len(resourceTypes)), defaultConfigSearchRequest, resourceTypes), nil +} + +func (h *serviceProviderHandler) GetResourceType(r *http.Request) (*sschemas.ResourceType, error) { + // the request is unauthenticated, read the orgID from the url instead of the context + ctx := r.Context() + vars := mux.Vars(r) + orgID := vars[zhttp.OrgIdInPathVariableName] + name := sschemas.ScimResourceTypeSingular(vars["name"]) + + schema, ok := h.schemasByResourceName[name] + if !ok { + return nil, zerrors.ThrowNotFoundf(nil, "SCIMSP-148z", "Scim resource type %s not found", name) + } + + return schema.ToResourceType(ctx, orgID), nil +} + +func (h *serviceProviderHandler) ListSchemas(r *http.Request) (*sresources.ListResponse[*sschemas.ResourceSchema], error) { + // the request is unauthenticated, read the orgID from the url instead of the context + ctx := r.Context() + orgID := mux.Vars(r)[zhttp.OrgIdInPathVariableName] + + schemas := make([]*sschemas.ResourceSchema, len(h.schemas)) + for i, schema := range h.schemas { + schemas[i] = buildSchema(ctx, orgID, schema) + } + + return sresources.NewListResponse(uint64(len(h.schemas)), defaultConfigSearchRequest, schemas), nil +} + +func (h *serviceProviderHandler) GetSchema(r *http.Request) (*sschemas.ResourceSchema, error) { + // the request is unauthenticated, read the orgID from the url instead of the context + ctx := r.Context() + vars := mux.Vars(r) + orgID := vars[zhttp.OrgIdInPathVariableName] + id := sschemas.ScimSchemaType(vars["id"]) + + schema, ok := h.schemasByID[id] + if !ok { + return nil, zerrors.ThrowNotFoundf(nil, "SCIMSP-148y", "Scim schema %s not found", id) + } + + return buildSchema(ctx, orgID, schema), nil +} + +// buildSchema shallow copies the provided schema and sets the correct location based on the provided context information. +func buildSchema(ctx context.Context, orgID string, schema *sschemas.ResourceSchema) *sschemas.ResourceSchema { + newSchema := *schema + newSchema.Resource = &sschemas.Resource{ + ID: schema.Resource.ID, + Schemas: schema.Resource.Schemas, + Meta: &sschemas.ResourceMeta{ + ResourceType: schema.Resource.Meta.ResourceType, + Location: sschemas.BuildLocationWithOrg(ctx, orgID, sschemas.SchemasResourceType, string(schema.ID)), + }, + } + return &newSchema +} diff --git a/internal/integration/scim/client.go b/internal/integration/scim/client.go index d896c94794..6c2de7ac3d 100644 --- a/internal/integration/scim/client.go +++ b/internal/integration/scim/client.go @@ -125,6 +125,26 @@ func NewScimClient(target string) *Client { } } +func (c *Client) GetServiceProviderConfig(ctx context.Context, orgID string) ([]byte, error) { + return c.getWithRawResponse(ctx, orgID, "/ServiceProviderConfig") +} + +func (c *Client) GetSchemas(ctx context.Context, orgID string) ([]byte, error) { + return c.getWithRawResponse(ctx, orgID, "/Schemas") +} + +func (c *Client) GetSchema(ctx context.Context, orgID, schemaID string) ([]byte, error) { + return c.getWithRawResponse(ctx, orgID, "/Schemas/"+schemaID) +} + +func (c *Client) GetResourceTypes(ctx context.Context, orgID string) ([]byte, error) { + return c.getWithRawResponse(ctx, orgID, "/ResourceTypes") +} + +func (c *Client) GetResourceType(ctx context.Context, orgID, name string) ([]byte, error) { + return c.getWithRawResponse(ctx, orgID, "/ResourceTypes/"+name) +} + 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 { @@ -233,6 +253,29 @@ func (c *ResourceClient[T]) doWithBody(ctx context.Context, method, orgID, url s return responseEntity, doReq(c.client, req, responseEntity) } +func (c *Client) getWithRawResponse(ctx context.Context, orgID, url string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/"+orgID+url, nil) + if err != nil { + return nil, err + } + + resp, err := c.client.Do(req) + if err != nil { + return nil, err + } + + defer func() { + err := resp.Body.Close() + logging.OnError(err).Error("Failed to close response body") + }() + + if (resp.StatusCode / 100) != 2 { + return nil, readScimError(resp) + } + + return io.ReadAll(resp.Body) +} + func doReq(client *http.Client, req *http.Request, responseEntity interface{}) error { addTokenAsHeader(req)