From e47cc3269c0e190b5d91432525b3a1a8e0ac8d6a Mon Sep 17 00:00:00 2001 From: Denis Arh Date: Fri, 27 Nov 2020 08:23:26 +0100 Subject: [PATCH 01/12] Add basic SCIM implementation --- app/options.go | 2 + app/servers.go | 29 + pkg/options/SCIM.gen.go | 37 + pkg/options/SCIM.yaml | 8 + system/scim/Makefile | 11 + system/scim/README.adoc | 6 + .../resource_types/group_resource_type.json | 6 + .../resource_types/user_resource_type.json | 12 + system/scim/assets/schemas/core_schema.json | 128 +++ system/scim/assets/schemas/group_schema.json | 56 ++ .../user_enterprise_extension_schema.json | 75 ++ system/scim/assets/schemas/user_schema.json | 738 ++++++++++++++++++ system/scim/assets/static.go | 6 + system/scim/gen_response.go | 24 + system/scim/http.go | 14 + system/scim/routes.go | 54 ++ system/scim/user_handler.go | 144 ++++ system/scim/user_payloads.go | 115 +++ tests/system/main_test.go | 1 - tests/system/scim_test.go | 129 +++ 20 files changed, 1594 insertions(+), 1 deletion(-) create mode 100644 pkg/options/SCIM.gen.go create mode 100644 pkg/options/SCIM.yaml create mode 100644 system/scim/Makefile create mode 100644 system/scim/README.adoc create mode 100644 system/scim/assets/resource_types/group_resource_type.json create mode 100644 system/scim/assets/resource_types/user_resource_type.json create mode 100644 system/scim/assets/schemas/core_schema.json create mode 100644 system/scim/assets/schemas/group_schema.json create mode 100644 system/scim/assets/schemas/user_enterprise_extension_schema.json create mode 100644 system/scim/assets/schemas/user_schema.json create mode 100644 system/scim/assets/static.go create mode 100644 system/scim/gen_response.go create mode 100644 system/scim/http.go create mode 100644 system/scim/routes.go create mode 100644 system/scim/user_handler.go create mode 100644 system/scim/user_payloads.go create mode 100644 tests/system/scim_test.go diff --git a/app/options.go b/app/options.go index 64a8e7f18..3bd147a7e 100644 --- a/app/options.go +++ b/app/options.go @@ -23,6 +23,7 @@ type ( Websocket options.WebsocketOpt Eventbus options.EventbusOpt Federation options.FederationOpt + SCIM options.SCIMOpt } ) @@ -46,5 +47,6 @@ func NewOptions() *Options { Websocket: *options.Websocket(), Eventbus: *options.Eventbus(), Federation: *options.Federation(), + SCIM: *options.SCIM(), } } diff --git a/app/servers.go b/app/servers.go index 3026718ff..ab843df8d 100644 --- a/app/servers.go +++ b/app/servers.go @@ -7,8 +7,10 @@ import ( messagingRest "github.com/cortezaproject/corteza-server/messaging/rest" "github.com/cortezaproject/corteza-server/pkg/actionlog" "github.com/cortezaproject/corteza-server/pkg/api/server" + "github.com/cortezaproject/corteza-server/pkg/logger" "github.com/cortezaproject/corteza-server/pkg/webapp" systemRest "github.com/cortezaproject/corteza-server/system/rest" + "github.com/cortezaproject/corteza-server/system/scim" "github.com/go-chi/chi" "go.uber.org/zap" "net/http" @@ -80,6 +82,33 @@ func (app *CortezaApp) mountHttpRoutes(r chi.Router) { app.Log.Info("JSON REST API disabled") } + if app.Opt.SCIM.Enabled { + if app.Opt.SCIM.Secret == "" { + app.Log. + WithOptions(zap.AddStacktrace(zap.PanicLevel)). + Error("SCIM secret empty") + } + + var ( + baseUrl = "/" + strings.Trim(app.Opt.SCIM.BaseURL, "/") + ) + + app.Log.Debug( + "SCIM enabled", + zap.String("baseUrl", baseUrl), + logger.Mask("secret", app.Opt.SCIM.Secret), + ) + + r.Route(baseUrl, func(r chi.Router) { + + if !app.Opt.Environment.IsDevelopment() { + r.Use(scim.Guard(app.Opt.SCIM)) + } + + scim.Routes(r) + }) + } + if app.Opt.HTTPServer.WebappEnabled { r.Route("/"+webappBaseUrl, webapp.MakeWebappServer(app.Opt.HTTPServer)) diff --git a/pkg/options/SCIM.gen.go b/pkg/options/SCIM.gen.go new file mode 100644 index 000000000..800040438 --- /dev/null +++ b/pkg/options/SCIM.gen.go @@ -0,0 +1,37 @@ +package options + +// This file is auto-generated. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// Definitions file that controls how this file is generated: +// pkg/options/SCIM.yaml + +type ( + SCIMOpt struct { + Enabled bool `env:"SCIM_ENABLED"` + BaseURL string `env:"SCIM_BASE_URL"` + Secret string `env:"SCIM_SECRET"` + } +) + +// SCIM initializes and returns a SCIMOpt with default values +func SCIM() (o *SCIMOpt) { + o = &SCIMOpt{ + BaseURL: "/scim", + } + + fill(o) + + // Function that allows access to custom logic inside the parent function. + // The custom logic in the other file should be like: + // func (o *SCIM) Defaults() {...} + func(o interface{}) { + if def, ok := o.(interface{ Defaults() }); ok { + def.Defaults() + } + }(o) + + return +} diff --git a/pkg/options/SCIM.yaml b/pkg/options/SCIM.yaml new file mode 100644 index 000000000..d2b22dbbb --- /dev/null +++ b/pkg/options/SCIM.yaml @@ -0,0 +1,8 @@ +name: SCIM + +props: + - name: enabled + type: bool + - name: baseURL + default: "/scim" + - name: secret diff --git a/system/scim/Makefile b/system/scim/Makefile new file mode 100644 index 000000000..90886e21a --- /dev/null +++ b/system/scim/Makefile @@ -0,0 +1,11 @@ +.PHONY: clean all + +include ../../Makefile.inc + +all: static.go + +static.go: $(STATIK) + $(STATIK) -p assets -m -Z -f -src=$(@D)/assets + +clean: + rm -f static.go diff --git a/system/scim/README.adoc b/system/scim/README.adoc new file mode 100644 index 000000000..fed12c9f1 --- /dev/null +++ b/system/scim/README.adoc @@ -0,0 +1,6 @@ += SCIM Support for Corteza + +Here is a bare minimum support for SCIM. + +NOTE: Experiments with github.com/imulab/go-scim lib failed due to complexity of the implementation +and resources needed for bending the lib to our needs. diff --git a/system/scim/assets/resource_types/group_resource_type.json b/system/scim/assets/resource_types/group_resource_type.json new file mode 100644 index 000000000..d84c25f2f --- /dev/null +++ b/system/scim/assets/resource_types/group_resource_type.json @@ -0,0 +1,6 @@ +{ + "id": "Group", + "name": "Group", + "endpoint": "/Groups", + "schema": "urn:ietf:params:scim:schemas:core:2.0:Group" +} \ No newline at end of file diff --git a/system/scim/assets/resource_types/user_resource_type.json b/system/scim/assets/resource_types/user_resource_type.json new file mode 100644 index 000000000..0631a5820 --- /dev/null +++ b/system/scim/assets/resource_types/user_resource_type.json @@ -0,0 +1,12 @@ +{ + "id": "User", + "name": "User", + "endpoint": "/Users", + "schema": "urn:ietf:params:scim:schemas:core:2.0:User", + "schemaExtensions": [ + { + "schema": "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User", + "required": false + } + ] +} \ No newline at end of file diff --git a/system/scim/assets/schemas/core_schema.json b/system/scim/assets/schemas/core_schema.json new file mode 100644 index 000000000..5f7ccdc9b --- /dev/null +++ b/system/scim/assets/schemas/core_schema.json @@ -0,0 +1,128 @@ +{ + "id": "core", + "name": "Core", + "description": "Shared attributes for all SCIM resources", + "attributes": [ + { + "id": "schemas", + "name": "schemas", + "type": "reference", + "multiValued": true, + "required": true, + "caseExact": true, + "returned": "always", + "_index": 0, + "_path": "schemas", + "_annotations": { + "@AutoCompact": {} + } + }, + { + "id": "id", + "name": "id", + "type": "string", + "caseExact": true, + "returned": "always", + "mutability": "readOnly", + "uniqueness": "global", + "_index": 1, + "_path": "id", + "_annotations": { + "@ReadOnly": { + "reset": true, + "copy": true + }, + "@UUID": {} + } + }, + { + "id": "externalId", + "name": "externalId", + "type": "string", + "_index": 2, + "_path": "externalId" + }, + { + "id": "meta", + "name": "meta", + "type": "complex", + "mutability": "readOnly", + "_index": 3, + "_path": "meta", + "subAttributes": [ + { + "id": "meta.resourceType", + "name": "resourceType", + "type": "string", + "caseExact": true, + "mutability": "readOnly", + "_index": 0, + "_path": "meta.resourceType", + "_annotations": { + "@ReadOnly": { + "reset": true, + "copy": true + } + } + }, + { + "id": "meta.created", + "name": "created", + "type": "dateTime", + "mutability": "readOnly", + "_index": 1, + "_path": "meta.created", + "_annotations": { + "@ReadOnly": { + "reset": true, + "copy": true + } + } + }, + { + "id": "meta.lastModified", + "name": "lastModified", + "type": "dateTime", + "mutability": "readOnly", + "_index": 2, + "_path": "meta.lastModified", + "_annotations": { + "@ReadOnly": { + "reset": true, + "copy": true + } + } + }, + { + "id": "meta.location", + "name": "location", + "type": "reference", + "mutability": "readOnly", + "caseExact": true, + "_index": 3, + "_path": "meta.location", + "_annotations": { + "@ReadOnly": { + "reset": true, + "copy": true + } + } + }, + { + "id": "meta.version", + "name": "version", + "type": "string", + "mutability": "readOnly", + "_index": 4, + "_path": "meta.version", + "_annotations": { + "@ReadOnly": { + "reset": true, + "copy": true + } + } + } + ] + } + ] +} \ No newline at end of file diff --git a/system/scim/assets/schemas/group_schema.json b/system/scim/assets/schemas/group_schema.json new file mode 100644 index 000000000..e39765969 --- /dev/null +++ b/system/scim/assets/schemas/group_schema.json @@ -0,0 +1,56 @@ +{ + "id": "urn:ietf:params:scim:schemas:core:2.0:Group", + "name": "Group", + "description": "Defined attributes for the group schema", + "attributes": [ + { + "id": "urn:ietf:params:scim:schemas:core:2.0:Group:displayName", + "name": "displayName", + "type": "string", + "_index": 100, + "_path": "displayName" + }, + { + "id": "urn:ietf:params:scim:schemas:core:2.0:Group:members", + "name": "members", + "type": "complex", + "multiValued": true, + "subAttributes": [ + { + "id": "urn:ietf:params:scim:schemas:core:2.0:Group:members.value", + "name": "value", + "type": "string", + "mutability": "immutable", + "_index": 0, + "_path": "members.value", + "_annotations":{ + "@Identity": {} + } + }, + { + "id": "urn:ietf:params:scim:schemas:core:2.0:Group:members.$ref", + "name": "$ref", + "type": "reference", + "mutability": "immutable", + "_index": 1, + "_path": "members.$ref" + }, + { + "id": "urn:ietf:params:scim:schemas:core:2.0:Group:members.display", + "name": "display", + "type": "string", + "_index": 2, + "_path": "members.display" + } + ], + "_index": 101, + "_path": "members", + "_annotations": { + "@AutoCompact": {}, + "@ElementAnnotations": { + "@StateSummary": {} + } + } + } + ] +} \ No newline at end of file diff --git a/system/scim/assets/schemas/user_enterprise_extension_schema.json b/system/scim/assets/schemas/user_enterprise_extension_schema.json new file mode 100644 index 000000000..c356e850e --- /dev/null +++ b/system/scim/assets/schemas/user_enterprise_extension_schema.json @@ -0,0 +1,75 @@ +{ + "id": "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User", + "name": "Enterprise User", + "description": "Extension attributes for enterprises", + "attributes": [ + { + "id": "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber", + "name": "employeeNumber", + "type": "string", + "_index": 0, + "_path": "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber" + }, + { + "id": "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:costCenter", + "name": "costCenter", + "type": "string", + "_index": 1, + "_path": "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:costCenter" + }, + { + "id": "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:organization", + "name": "organization", + "type": "string", + "_index": 2, + "_path": "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:organization" + }, + { + "id": "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:division", + "name": "division", + "type": "string", + "_index": 3, + "_path": "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:division" + }, + { + "id": "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department", + "name": "department", + "type": "string", + "_index": 4, + "_path": "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department" + }, + { + "id": "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:manager", + "name": "manager", + "type": "complex", + "_index": 5, + "_path": "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:manager", + "_annotations": { + "@StateSummary": {} + }, + "subAttributes": [ + { + "id": "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:manager.value", + "name": "value", + "type": "string", + "_index": 0, + "_path": "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:manager.value" + }, + { + "id": "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:manager.$ref", + "name": "$ref", + "type": "reference", + "_index": 1, + "_path": "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:manager.$ref" + }, + { + "id": "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:manager.displayName", + "name": "displayName", + "type": "string", + "_index": 2, + "_path": "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:manager.displayName" + } + ] + } + ] +} \ No newline at end of file diff --git a/system/scim/assets/schemas/user_schema.json b/system/scim/assets/schemas/user_schema.json new file mode 100644 index 000000000..8b49a2d03 --- /dev/null +++ b/system/scim/assets/schemas/user_schema.json @@ -0,0 +1,738 @@ +{ + "id": "urn:ietf:params:scim:schemas:core:2.0:User", + "name": "User", + "description": "Defined attributes for the user schema", + "attributes": [ + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:userName", + "name": "userName", + "type": "string", + "required": true, + "uniqueness": "server", + "_index": 100, + "_path": "userName" + }, + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:name", + "name": "name", + "type": "complex", + "_index": 101, + "_path": "name", + "_annotations": { + "@StateSummary": {} + }, + "subAttributes": [ + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:name.formatted", + "name": "formatted", + "type": "string", + "_index": 0, + "_path": "name.formatted", + "_annotations": { + "@Identity": {} + } + }, + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:name.familyName", + "name": "familyName", + "type": "string", + "_index": 1, + "_path": "name.familyName", + "_annotations": { + "@Identity": {} + } + }, + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:name.givenName", + "name": "givenName", + "type": "string", + "_index": 2, + "_path": "name.givenName", + "_annotations": { + "@Identity": {} + } + }, + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:name.middleName", + "name": "middleName", + "type": "string", + "_index": 3, + "_path": "name.middleName", + "_annotations": { + "@Identity": {} + } + }, + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:name.honorificPrefix", + "name": "honorificPrefix", + "type": "string", + "_index": 4, + "_path": "name.honorificPrefix", + "_annotations": { + "@Identity": {} + } + }, + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:name.honorificSuffix", + "name": "honorificSuffix", + "type": "string", + "_index": 5, + "_path": "name.honorificSuffix", + "_annotations": { + "@Identity": {} + } + } + ] + }, + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:displayName", + "name": "displayName", + "type": "string", + "_index": 102, + "_path": "displayName" + }, + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:nickName", + "name": "nickName", + "type": "string", + "_index": 103, + "_path": "nickName" + }, + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:profileUrl", + "name": "profileUrl", + "type": "reference", + "referenceTypes": [ + "external" + ], + "_index": 104, + "_path": "profileUrl" + }, + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:title", + "name": "title", + "type": "string", + "_index": 105, + "_path": "title" + }, + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:userType", + "name": "userType", + "type": "string", + "canonicalValues": [ + "Employee", + "Intern" + ], + "_index": 106, + "_path": "userType" + }, + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:preferredLanguage", + "name": "preferredLanguage", + "type": "string", + "canonicalValues": [ + "zh_CN", + "en_US" + ], + "_index": 107, + "_path": "preferredLanguage" + }, + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:locale", + "name": "locale", + "type": "string", + "canonicalValues": [ + "en_US", + "zh_CN" + ], + "_index": 108, + "_path": "locale" + }, + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:timezone", + "name": "timezone", + "type": "string", + "canonicalValues": [ + "Asia/Shanghai", + "Asia/Beijing", + "America/New_York", + "America/Toronto" + ], + "_index": 109, + "_path": "timezone" + }, + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:active", + "name": "active", + "type": "boolean", + "_index": 110, + "_path": "active" + }, + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:password", + "name": "password", + "type": "string", + "mutability": "writeOnly", + "returned": "never", + "_index": 111, + "_path": "password", + "_annotations": { + "@BCrypt": { + "cost": 10 + } + } + }, + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:emails", + "name": "emails", + "type": "complex", + "multiValued": true, + "required": true, + "_index": 112, + "_path": "emails", + "_annotations": { + "@AutoCompact": {}, + "@ExclusivePrimary": {}, + "@ElementAnnotations": { + "@StateSummary": {} + } + }, + "subAttributes": [ + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:emails.value", + "name": "value", + "type": "string", + "_index": 0, + "_path": "emails.value", + "_annotations": { + "@Identity": {} + } + }, + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:emails.type", + "name": "type", + "type": "string", + "canonicalValues": [ + "work", + "home", + "other" + ], + "_index": 1, + "_path": "emails.type", + "_annotations": { + "@Identity": {} + } + }, + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:emails.primary", + "name": "primary", + "type": "boolean", + "_index": 2, + "_path": "emails.primary", + "_annotations": { + "@Primary": {} + } + }, + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:emails.display", + "name": "display", + "type": "string", + "_index": 3, + "_path": "emails.display" + } + ] + }, + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:phoneNumbers", + "name": "phoneNumbers", + "type": "complex", + "multiValued": true, + "_index": 113, + "_path": "phoneNumbers", + "_annotations": { + "@AutoCompact": {}, + "@ExclusivePrimary": {}, + "@ElementAnnotations": { + "@StateSummary": {} + } + }, + "subAttributes": [ + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:phoneNumbers.value", + "name": "value", + "type": "string", + "_index": 0, + "_path": "phoneNumbers.value", + "_annotations": { + "@Identity": {} + } + }, + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:phoneNumbers.type", + "name": "type", + "type": "string", + "canonicalValues": [ + "work", + "home", + "mobile", + "fax", + "other" + ], + "_index": 1, + "_path": "phoneNumbers.type", + "_annotations": { + "@Identity": {} + } + }, + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:phoneNumbers.primary", + "name": "primary", + "type": "boolean", + "_index": 2, + "_path": "phoneNumbers.primary", + "_annotations": { + "@Primary": {} + } + }, + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:phoneNumbers.display", + "name": "display", + "type": "string", + "_index": 3, + "_path": "phoneNumbers.display" + } + ] + }, + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:ims", + "name": "ims", + "type": "complex", + "multiValued": true, + "_index": 114, + "_path": "ims", + "_annotations": { + "@AutoCompact": {}, + "@ExclusivePrimary": {}, + "@ElementAnnotations": { + "@StateSummary": {} + } + }, + "subAttributes": [ + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:ims.value", + "name": "value", + "type": "string", + "_index": 0, + "_path": "ims.value", + "_annotations": { + "@Identity": {} + } + }, + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:ims.type", + "name": "type", + "type": "string", + "canonicalValues": [ + "skype", + "qq", + "wechat", + "weibo", + "other" + ], + "_index": 1, + "_path": "ims.type", + "_annotations": { + "@Identity": {} + } + }, + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:ims.primary", + "name": "primary", + "type": "boolean", + "_index": 2, + "_path": "ims.primary", + "_annotations": { + "@Primary": {} + } + }, + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:ims.display", + "name": "display", + "type": "string", + "_index": 3, + "_path": "ims.display" + } + ] + }, + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:photos", + "name": "photos", + "type": "complex", + "multiValued": true, + "_index": 115, + "_path": "photos", + "_annotations": { + "@AutoCompact": {}, + "@ExclusivePrimary": {}, + "@ElementAnnotations": { + "@StateSummary": {} + } + }, + "subAttributes": [ + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:photos.value", + "name": "value", + "type": "reference", + "referenceTypes": [ + "external" + ], + "_index": 0, + "_path": "photos.value", + "_annotations": { + "@Identity": {} + } + }, + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:photos.type", + "name": "type", + "type": "string", + "canonicalValues": [ + "photo", + "thumbnail" + ], + "_index": 1, + "_path": "photos.type", + "_annotations": { + "@Identity": {} + } + }, + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:photos.primary", + "name": "primary", + "type": "boolean", + "_index": 2, + "_path": "photos.primary", + "_annotations": { + "@Primary": {} + } + } + ] + }, + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:addresses", + "name": "addresses", + "type": "complex", + "multiValued": true, + "_index": 116, + "_path": "addresses", + "_annotations": { + "@AutoCompact": {}, + "@ExclusivePrimary": {}, + "@ElementAnnotations": { + "@StateSummary": {} + } + }, + "subAttributes": [ + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:addresses.formatted", + "name": "formatted", + "type": "string", + "_index": 0, + "_path": "photos.formatted" + }, + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:addresses.streetAddress", + "name": "streetAddress", + "type": "string", + "_index": 1, + "_path": "photos.streetAddress", + "_annotations": { + "@Identity": {} + } + }, + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:addresses.locality", + "name": "locality", + "type": "string", + "_index": 2, + "_path": "photos.locality", + "_annotations": { + "@Identity": {} + } + }, + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:addresses.region", + "name": "region", + "type": "string", + "_index": 3, + "_path": "photos.region", + "_annotations": { + "@Identity": {} + } + }, + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:addresses.postalCode", + "name": "postalCode", + "type": "string", + "_index": 4, + "_path": "photos.postalCode", + "_annotations": { + "@Identity": {} + } + }, + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:addresses.country", + "name": "country", + "type": "string", + "_index": 5, + "_path": "photos.country", + "_annotations": { + "@Identity": {} + } + }, + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:addresses.type", + "name": "type", + "type": "string", + "canonicalValues": [ + "work", + "home", + "id", + "driver", + "other" + ], + "_index": 6, + "_path": "photos.type", + "_annotations": { + "@Identity": {} + } + }, + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:addresses.primary", + "name": "primary", + "type": "boolean", + "_index": 7, + "_path": "photos.primary", + "_annotations": { + "@Primary": {} + } + } + ] + }, + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:groups", + "name": "groups", + "type": "complex", + "multiValued": true, + "mutability": "readOnly", + "_index": 117, + "_path": "groups", + "_annotations": { + "@ReadOnly": { + "reset": true, + "copy": true + } + }, + "subAttributes": [ + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:groups.value", + "name": "value", + "type": "string", + "mutability": "readOnly", + "_index": 0, + "_path": "groups.value" + }, + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:groups.$ref", + "name": "$ref", + "type": "reference", + "mutability": "readOnly", + "_index": 1, + "_path": "groups.$ref" + }, + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:groups.type", + "name": "type", + "type": "string", + "mutability": "readOnly", + "canonicalValues": [ + "direct", + "indirect" + ], + "_index": 2, + "_path": "groups.type" + }, + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:groups.display", + "name": "display", + "type": "string", + "mutability": "readOnly", + "_index": 3, + "_path": "groups.display" + } + ] + }, + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:entitlements", + "name": "entitlements", + "type": "complex", + "multiValued": true, + "_index": 118, + "_path": "entitlements", + "_annotations": { + "@AutoCompact": {}, + "@ExclusivePrimary": {}, + "@ElementAnnotations": { + "@StateSummary": {} + } + }, + "subAttributes": [ + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:entitlements.value", + "name": "value", + "type": "string", + "_index": 0, + "_path": "entitlements.value", + "_annotations": { + "@Identity": {} + } + }, + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:entitlements.type", + "name": "type", + "type": "string", + "_index": 0, + "_path": "entitlements.type", + "_annotations": { + "@Identity": {} + } + }, + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:entitlements.primary", + "name": "primary", + "type": "boolean", + "_index": 0, + "_path": "entitlements.primary", + "_annotations": { + "@Primary": {} + } + }, + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:entitlements.display", + "name": "display", + "type": "string", + "_index": 0, + "_path": "entitlements.display" + } + ] + }, + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:roles", + "name": "roles", + "type": "complex", + "multiValued": true, + "_index": 119, + "_path": "roles", + "_annotations": { + "@AutoCompact": {}, + "@ExclusivePrimary": {}, + "@ElementAnnotations": { + "@StateSummary": {} + } + }, + "subAttributes": [ + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:roles.value", + "name": "value", + "type": "string", + "_index": 0, + "_path": "roles.value", + "_annotations": { + "@Identity": {} + } + }, + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:roles.type", + "name": "type", + "type": "string", + "_index": 1, + "_path": "roles.type", + "_annotations": { + "@Identity": {} + } + }, + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:roles.primary", + "name": "primary", + "type": "boolean", + "_index": 2, + "_path": "roles.primary", + "_annotations": { + "@Primary": {} + } + }, + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:roles.display", + "name": "display", + "type": "string", + "_index": 3, + "_path": "roles.display" + } + ] + }, + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:x509Certificates", + "name": "x509Certificates", + "type": "complex", + "multiValued": true, + "_index": 120, + "_path": "x509Certificates", + "_annotations": { + "@AutoCompact": {}, + "@ExclusivePrimary": {}, + "@ElementAnnotations": { + "@StateSummary": {} + } + }, + "subAttributes": [ + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:x509Certificates.value", + "name": "value", + "type": "binary", + "_index": 0, + "_path": "x509Certificates.value", + "_annotations": { + "@Identity": {} + } + }, + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:x509Certificates.type", + "name": "type", + "type": "string", + "_index": 1, + "_path": "x509Certificates.type", + "_annotations": { + "@Identity": {} + } + }, + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:x509Certificates.primary", + "name": "primary", + "type": "boolean", + "_index": 2, + "_path": "x509Certificates.primary", + "_annotations": { + "@Primary": {} + } + }, + { + "id": "urn:ietf:params:scim:schemas:core:2.0:User:x509Certificates.display", + "name": "display", + "type": "string", + "_index": 3, + "_path": "x509Certificates.display" + } + ] + } + ] +} \ No newline at end of file diff --git a/system/scim/assets/static.go b/system/scim/assets/static.go new file mode 100644 index 000000000..250544682 --- /dev/null +++ b/system/scim/assets/static.go @@ -0,0 +1,6 @@ +// Code generated by statik. DO NOT EDIT. + +// Package contains static assets. +package assets + +var Asset = "PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'\x00 \x00resource_types/group_resource_type.jsonUT\x05\x00\x01\x80Cm8{\n \"id\": \"Group\",\n \"name\": \"Group\",\n \"endpoint\": \"/Groups\",\n \"schema\": \"urn:ietf:params:scim:schemas:core:2.0:Group\"\n}PK\x07\x08E*\x91~z\x00\x00\x00z\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00&\x00 \x00resource_types/user_resource_type.jsonUT\x05\x00\x01\x80Cm8{\n \"id\": \"User\",\n \"name\": \"User\",\n \"endpoint\": \"/Users\",\n \"schema\": \"urn:ietf:params:scim:schemas:core:2.0:User\",\n \"schemaExtensions\": [\n {\n \"schema\": \"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User\",\n \"required\": false\n }\n ]\n}PK\x07\x08\x10\xd6\x95\x11\x05\x01\x00\x00\x05\x01\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x18\x00 \x00schemas/core_schema.jsonUT\x05\x00\x01\x80Cm8{\n \"id\": \"core\",\n \"name\": \"Core\",\n \"description\": \"Shared attributes for all SCIM resources\",\n \"attributes\": [\n {\n \"id\": \"schemas\",\n \"name\": \"schemas\",\n \"type\": \"reference\",\n \"multiValued\": true,\n \"required\": true,\n \"caseExact\": true,\n \"returned\": \"always\",\n \"_index\": 0,\n \"_path\": \"schemas\",\n \"_annotations\": {\n \"@AutoCompact\": {}\n }\n },\n {\n \"id\": \"id\",\n \"name\": \"id\",\n \"type\": \"string\",\n \"caseExact\": true,\n \"returned\": \"always\",\n \"mutability\": \"readOnly\",\n \"uniqueness\": \"global\",\n \"_index\": 1,\n \"_path\": \"id\",\n \"_annotations\": {\n \"@ReadOnly\": {\n \"reset\": true,\n \"copy\": true\n },\n \"@UUID\": {}\n }\n },\n {\n \"id\": \"externalId\",\n \"name\": \"externalId\",\n \"type\": \"string\",\n \"_index\": 2,\n \"_path\": \"externalId\"\n },\n {\n \"id\": \"meta\",\n \"name\": \"meta\",\n \"type\": \"complex\",\n \"mutability\": \"readOnly\",\n \"_index\": 3,\n \"_path\": \"meta\",\n \"subAttributes\": [\n {\n \"id\": \"meta.resourceType\",\n \"name\": \"resourceType\",\n \"type\": \"string\",\n \"caseExact\": true,\n \"mutability\": \"readOnly\",\n \"_index\": 0,\n \"_path\": \"meta.resourceType\",\n \"_annotations\": {\n \"@ReadOnly\": {\n \"reset\": true,\n \"copy\": true\n }\n }\n },\n {\n \"id\": \"meta.created\",\n \"name\": \"created\",\n \"type\": \"dateTime\",\n \"mutability\": \"readOnly\",\n \"_index\": 1,\n \"_path\": \"meta.created\",\n \"_annotations\": {\n \"@ReadOnly\": {\n \"reset\": true,\n \"copy\": true\n }\n }\n },\n {\n \"id\": \"meta.lastModified\",\n \"name\": \"lastModified\",\n \"type\": \"dateTime\",\n \"mutability\": \"readOnly\",\n \"_index\": 2,\n \"_path\": \"meta.lastModified\",\n \"_annotations\": {\n \"@ReadOnly\": {\n \"reset\": true,\n \"copy\": true\n }\n }\n },\n {\n \"id\": \"meta.location\",\n \"name\": \"location\",\n \"type\": \"reference\",\n \"mutability\": \"readOnly\",\n \"caseExact\": true,\n \"_index\": 3,\n \"_path\": \"meta.location\",\n \"_annotations\": {\n \"@ReadOnly\": {\n \"reset\": true,\n \"copy\": true\n }\n }\n },\n {\n \"id\": \"meta.version\",\n \"name\": \"version\",\n \"type\": \"string\",\n \"mutability\": \"readOnly\",\n \"_index\": 4,\n \"_path\": \"meta.version\",\n \"_annotations\": {\n \"@ReadOnly\": {\n \"reset\": true,\n \"copy\": true\n }\n }\n }\n ]\n }\n ]\n}PK\x07\x08\xe9\xbf\x88(Z\x0b\x00\x00Z\x0b\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x19\x00 \x00schemas/group_schema.jsonUT\x05\x00\x01\x80Cm8{\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:Group\",\n \"name\": \"Group\",\n \"description\": \"Defined attributes for the group schema\",\n \"attributes\": [\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:Group:displayName\",\n \"name\": \"displayName\",\n \"type\": \"string\",\n \"_index\": 100,\n \"_path\": \"displayName\"\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:Group:members\",\n \"name\": \"members\",\n \"type\": \"complex\",\n \"multiValued\": true,\n \"subAttributes\": [\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:Group:members.value\",\n \"name\": \"value\",\n \"type\": \"string\",\n \"mutability\": \"immutable\",\n \"_index\": 0,\n \"_path\": \"members.value\",\n \"_annotations\":{\n \"@Identity\": {}\n }\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:Group:members.$ref\",\n \"name\": \"$ref\",\n \"type\": \"reference\",\n \"mutability\": \"immutable\",\n \"_index\": 1,\n \"_path\": \"members.$ref\"\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:Group:members.display\",\n \"name\": \"display\",\n \"type\": \"string\",\n \"_index\": 2,\n \"_path\": \"members.display\"\n }\n ],\n \"_index\": 101,\n \"_path\": \"members\",\n \"_annotations\": {\n \"@AutoCompact\": {},\n \"@ElementAnnotations\": {\n \"@StateSummary\": {}\n }\n }\n }\n ]\n}PK\x07\x08\xbdW;\xa3\xd8\x05\x00\x00\xd8\x05\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00-\x00 \x00schemas/user_enterprise_extension_schema.jsonUT\x05\x00\x01\x80Cm8{\n \"id\": \"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User\",\n \"name\": \"Enterprise User\",\n \"description\": \"Extension attributes for enterprises\",\n \"attributes\": [\n {\n \"id\": \"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber\",\n \"name\": \"employeeNumber\",\n \"type\": \"string\",\n \"_index\": 0,\n \"_path\": \"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber\"\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:costCenter\",\n \"name\": \"costCenter\",\n \"type\": \"string\",\n \"_index\": 1,\n \"_path\": \"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:costCenter\"\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:organization\",\n \"name\": \"organization\",\n \"type\": \"string\",\n \"_index\": 2,\n \"_path\": \"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:organization\"\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:division\",\n \"name\": \"division\",\n \"type\": \"string\",\n \"_index\": 3,\n \"_path\": \"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:division\"\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department\",\n \"name\": \"department\",\n \"type\": \"string\",\n \"_index\": 4,\n \"_path\": \"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department\"\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:manager\",\n \"name\": \"manager\",\n \"type\": \"complex\",\n \"_index\": 5,\n \"_path\": \"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:manager\",\n \"_annotations\": {\n \"@StateSummary\": {}\n },\n \"subAttributes\": [\n {\n \"id\": \"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:manager.value\",\n \"name\": \"value\",\n \"type\": \"string\",\n \"_index\": 0,\n \"_path\": \"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:manager.value\"\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:manager.$ref\",\n \"name\": \"$ref\",\n \"type\": \"reference\",\n \"_index\": 1,\n \"_path\": \"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:manager.$ref\"\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:manager.displayName\",\n \"name\": \"displayName\",\n \"type\": \"string\",\n \"_index\": 2,\n \"_path\": \"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:manager.displayName\"\n }\n ]\n }\n ]\n}PK\x07\x08iu\xb0\xe1\x7f\n\x00\x00\x7f\n\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x18\x00 \x00schemas/user_schema.jsonUT\x05\x00\x01\x80Cm8{\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User\",\n \"name\": \"User\",\n \"description\": \"Defined attributes for the user schema\",\n \"attributes\": [\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:userName\",\n \"name\": \"userName\",\n \"type\": \"string\",\n \"required\": true,\n \"uniqueness\": \"server\",\n \"_index\": 100,\n \"_path\": \"userName\"\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:name\",\n \"name\": \"name\",\n \"type\": \"complex\",\n \"_index\": 101,\n \"_path\": \"name\",\n \"_annotations\": {\n \"@StateSummary\": {}\n },\n \"subAttributes\": [\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:name.formatted\",\n \"name\": \"formatted\",\n \"type\": \"string\",\n \"_index\": 0,\n \"_path\": \"name.formatted\",\n \"_annotations\": {\n \"@Identity\": {}\n }\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:name.familyName\",\n \"name\": \"familyName\",\n \"type\": \"string\",\n \"_index\": 1,\n \"_path\": \"name.familyName\",\n \"_annotations\": {\n \"@Identity\": {}\n }\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:name.givenName\",\n \"name\": \"givenName\",\n \"type\": \"string\",\n \"_index\": 2,\n \"_path\": \"name.givenName\",\n \"_annotations\": {\n \"@Identity\": {}\n }\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:name.middleName\",\n \"name\": \"middleName\",\n \"type\": \"string\",\n \"_index\": 3,\n \"_path\": \"name.middleName\",\n \"_annotations\": {\n \"@Identity\": {}\n }\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:name.honorificPrefix\",\n \"name\": \"honorificPrefix\",\n \"type\": \"string\",\n \"_index\": 4,\n \"_path\": \"name.honorificPrefix\",\n \"_annotations\": {\n \"@Identity\": {}\n }\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:name.honorificSuffix\",\n \"name\": \"honorificSuffix\",\n \"type\": \"string\",\n \"_index\": 5,\n \"_path\": \"name.honorificSuffix\",\n \"_annotations\": {\n \"@Identity\": {}\n }\n }\n ]\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:displayName\",\n \"name\": \"displayName\",\n \"type\": \"string\",\n \"_index\": 102,\n \"_path\": \"displayName\"\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:nickName\",\n \"name\": \"nickName\",\n \"type\": \"string\",\n \"_index\": 103,\n \"_path\": \"nickName\"\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:profileUrl\",\n \"name\": \"profileUrl\",\n \"type\": \"reference\",\n \"referenceTypes\": [\n \"external\"\n ],\n \"_index\": 104,\n \"_path\": \"profileUrl\"\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:title\",\n \"name\": \"title\",\n \"type\": \"string\",\n \"_index\": 105,\n \"_path\": \"title\"\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:userType\",\n \"name\": \"userType\",\n \"type\": \"string\",\n \"canonicalValues\": [\n \"Employee\",\n \"Intern\"\n ],\n \"_index\": 106,\n \"_path\": \"userType\"\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:preferredLanguage\",\n \"name\": \"preferredLanguage\",\n \"type\": \"string\",\n \"canonicalValues\": [\n \"zh_CN\",\n \"en_US\"\n ],\n \"_index\": 107,\n \"_path\": \"preferredLanguage\"\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:locale\",\n \"name\": \"locale\",\n \"type\": \"string\",\n \"canonicalValues\": [\n \"en_US\",\n \"zh_CN\"\n ],\n \"_index\": 108,\n \"_path\": \"locale\"\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:timezone\",\n \"name\": \"timezone\",\n \"type\": \"string\",\n \"canonicalValues\": [\n \"Asia/Shanghai\",\n \"Asia/Beijing\",\n \"America/New_York\",\n \"America/Toronto\"\n ],\n \"_index\": 109,\n \"_path\": \"timezone\"\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:active\",\n \"name\": \"active\",\n \"type\": \"boolean\",\n \"_index\": 110,\n \"_path\": \"active\"\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:password\",\n \"name\": \"password\",\n \"type\": \"string\",\n \"mutability\": \"writeOnly\",\n \"returned\": \"never\",\n \"_index\": 111,\n \"_path\": \"password\",\n \"_annotations\": {\n \"@BCrypt\": {\n \"cost\": 10\n }\n }\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:emails\",\n \"name\": \"emails\",\n \"type\": \"complex\",\n \"multiValued\": true,\n \"required\": true,\n \"_index\": 112,\n \"_path\": \"emails\",\n \"_annotations\": {\n \"@AutoCompact\": {},\n \"@ExclusivePrimary\": {},\n \"@ElementAnnotations\": {\n \"@StateSummary\": {}\n }\n },\n \"subAttributes\": [\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:emails.value\",\n \"name\": \"value\",\n \"type\": \"string\",\n \"_index\": 0,\n \"_path\": \"emails.value\",\n \"_annotations\": {\n \"@Identity\": {}\n }\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:emails.type\",\n \"name\": \"type\",\n \"type\": \"string\",\n \"canonicalValues\": [\n \"work\",\n \"home\",\n \"other\"\n ],\n \"_index\": 1,\n \"_path\": \"emails.type\",\n \"_annotations\": {\n \"@Identity\": {}\n }\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:emails.primary\",\n \"name\": \"primary\",\n \"type\": \"boolean\",\n \"_index\": 2,\n \"_path\": \"emails.primary\",\n \"_annotations\": {\n \"@Primary\": {}\n }\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:emails.display\",\n \"name\": \"display\",\n \"type\": \"string\",\n \"_index\": 3,\n \"_path\": \"emails.display\"\n }\n ]\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:phoneNumbers\",\n \"name\": \"phoneNumbers\",\n \"type\": \"complex\",\n \"multiValued\": true,\n \"_index\": 113,\n \"_path\": \"phoneNumbers\",\n \"_annotations\": {\n \"@AutoCompact\": {},\n \"@ExclusivePrimary\": {},\n \"@ElementAnnotations\": {\n \"@StateSummary\": {}\n }\n },\n \"subAttributes\": [\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:phoneNumbers.value\",\n \"name\": \"value\",\n \"type\": \"string\",\n \"_index\": 0,\n \"_path\": \"phoneNumbers.value\",\n \"_annotations\": {\n \"@Identity\": {}\n }\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:phoneNumbers.type\",\n \"name\": \"type\",\n \"type\": \"string\",\n \"canonicalValues\": [\n \"work\",\n \"home\",\n \"mobile\",\n \"fax\",\n \"other\"\n ],\n \"_index\": 1,\n \"_path\": \"phoneNumbers.type\",\n \"_annotations\": {\n \"@Identity\": {}\n }\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:phoneNumbers.primary\",\n \"name\": \"primary\",\n \"type\": \"boolean\",\n \"_index\": 2,\n \"_path\": \"phoneNumbers.primary\",\n \"_annotations\": {\n \"@Primary\": {}\n }\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:phoneNumbers.display\",\n \"name\": \"display\",\n \"type\": \"string\",\n \"_index\": 3,\n \"_path\": \"phoneNumbers.display\"\n }\n ]\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:ims\",\n \"name\": \"ims\",\n \"type\": \"complex\",\n \"multiValued\": true,\n \"_index\": 114,\n \"_path\": \"ims\",\n \"_annotations\": {\n \"@AutoCompact\": {},\n \"@ExclusivePrimary\": {},\n \"@ElementAnnotations\": {\n \"@StateSummary\": {}\n }\n },\n \"subAttributes\": [\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:ims.value\",\n \"name\": \"value\",\n \"type\": \"string\",\n \"_index\": 0,\n \"_path\": \"ims.value\",\n \"_annotations\": {\n \"@Identity\": {}\n }\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:ims.type\",\n \"name\": \"type\",\n \"type\": \"string\",\n \"canonicalValues\": [\n \"skype\",\n \"qq\",\n \"wechat\",\n \"weibo\",\n \"other\"\n ],\n \"_index\": 1,\n \"_path\": \"ims.type\",\n \"_annotations\": {\n \"@Identity\": {}\n }\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:ims.primary\",\n \"name\": \"primary\",\n \"type\": \"boolean\",\n \"_index\": 2,\n \"_path\": \"ims.primary\",\n \"_annotations\": {\n \"@Primary\": {}\n }\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:ims.display\",\n \"name\": \"display\",\n \"type\": \"string\",\n \"_index\": 3,\n \"_path\": \"ims.display\"\n }\n ]\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:photos\",\n \"name\": \"photos\",\n \"type\": \"complex\",\n \"multiValued\": true,\n \"_index\": 115,\n \"_path\": \"photos\",\n \"_annotations\": {\n \"@AutoCompact\": {},\n \"@ExclusivePrimary\": {},\n \"@ElementAnnotations\": {\n \"@StateSummary\": {}\n }\n },\n \"subAttributes\": [\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:photos.value\",\n \"name\": \"value\",\n \"type\": \"reference\",\n \"referenceTypes\": [\n \"external\"\n ],\n \"_index\": 0,\n \"_path\": \"photos.value\",\n \"_annotations\": {\n \"@Identity\": {}\n }\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:photos.type\",\n \"name\": \"type\",\n \"type\": \"string\",\n \"canonicalValues\": [\n \"photo\",\n \"thumbnail\"\n ],\n \"_index\": 1,\n \"_path\": \"photos.type\",\n \"_annotations\": {\n \"@Identity\": {}\n }\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:photos.primary\",\n \"name\": \"primary\",\n \"type\": \"boolean\",\n \"_index\": 2,\n \"_path\": \"photos.primary\",\n \"_annotations\": {\n \"@Primary\": {}\n }\n }\n ]\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:addresses\",\n \"name\": \"addresses\",\n \"type\": \"complex\",\n \"multiValued\": true,\n \"_index\": 116,\n \"_path\": \"addresses\",\n \"_annotations\": {\n \"@AutoCompact\": {},\n \"@ExclusivePrimary\": {},\n \"@ElementAnnotations\": {\n \"@StateSummary\": {}\n }\n },\n \"subAttributes\": [\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:addresses.formatted\",\n \"name\": \"formatted\",\n \"type\": \"string\",\n \"_index\": 0,\n \"_path\": \"photos.formatted\"\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:addresses.streetAddress\",\n \"name\": \"streetAddress\",\n \"type\": \"string\",\n \"_index\": 1,\n \"_path\": \"photos.streetAddress\",\n \"_annotations\": {\n \"@Identity\": {}\n }\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:addresses.locality\",\n \"name\": \"locality\",\n \"type\": \"string\",\n \"_index\": 2,\n \"_path\": \"photos.locality\",\n \"_annotations\": {\n \"@Identity\": {}\n }\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:addresses.region\",\n \"name\": \"region\",\n \"type\": \"string\",\n \"_index\": 3,\n \"_path\": \"photos.region\",\n \"_annotations\": {\n \"@Identity\": {}\n }\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:addresses.postalCode\",\n \"name\": \"postalCode\",\n \"type\": \"string\",\n \"_index\": 4,\n \"_path\": \"photos.postalCode\",\n \"_annotations\": {\n \"@Identity\": {}\n }\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:addresses.country\",\n \"name\": \"country\",\n \"type\": \"string\",\n \"_index\": 5,\n \"_path\": \"photos.country\",\n \"_annotations\": {\n \"@Identity\": {}\n }\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:addresses.type\",\n \"name\": \"type\",\n \"type\": \"string\",\n \"canonicalValues\": [\n \"work\",\n \"home\",\n \"id\",\n \"driver\",\n \"other\"\n ],\n \"_index\": 6,\n \"_path\": \"photos.type\",\n \"_annotations\": {\n \"@Identity\": {}\n }\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:addresses.primary\",\n \"name\": \"primary\",\n \"type\": \"boolean\",\n \"_index\": 7,\n \"_path\": \"photos.primary\",\n \"_annotations\": {\n \"@Primary\": {}\n }\n }\n ]\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:groups\",\n \"name\": \"groups\",\n \"type\": \"complex\",\n \"multiValued\": true,\n \"mutability\": \"readOnly\",\n \"_index\": 117,\n \"_path\": \"groups\",\n \"_annotations\": {\n \"@ReadOnly\": {\n \"reset\": true,\n \"copy\": true\n }\n },\n \"subAttributes\": [\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:groups.value\",\n \"name\": \"value\",\n \"type\": \"string\",\n \"mutability\": \"readOnly\",\n \"_index\": 0,\n \"_path\": \"groups.value\"\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:groups.$ref\",\n \"name\": \"$ref\",\n \"type\": \"reference\",\n \"mutability\": \"readOnly\",\n \"_index\": 1,\n \"_path\": \"groups.$ref\"\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:groups.type\",\n \"name\": \"type\",\n \"type\": \"string\",\n \"mutability\": \"readOnly\",\n \"canonicalValues\": [\n \"direct\",\n \"indirect\"\n ],\n \"_index\": 2,\n \"_path\": \"groups.type\"\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:groups.display\",\n \"name\": \"display\",\n \"type\": \"string\",\n \"mutability\": \"readOnly\",\n \"_index\": 3,\n \"_path\": \"groups.display\"\n }\n ]\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:entitlements\",\n \"name\": \"entitlements\",\n \"type\": \"complex\",\n \"multiValued\": true,\n \"_index\": 118,\n \"_path\": \"entitlements\",\n \"_annotations\": {\n \"@AutoCompact\": {},\n \"@ExclusivePrimary\": {},\n \"@ElementAnnotations\": {\n \"@StateSummary\": {}\n }\n },\n \"subAttributes\": [\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:entitlements.value\",\n \"name\": \"value\",\n \"type\": \"string\",\n \"_index\": 0,\n \"_path\": \"entitlements.value\",\n \"_annotations\": {\n \"@Identity\": {}\n }\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:entitlements.type\",\n \"name\": \"type\",\n \"type\": \"string\",\n \"_index\": 0,\n \"_path\": \"entitlements.type\",\n \"_annotations\": {\n \"@Identity\": {}\n }\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:entitlements.primary\",\n \"name\": \"primary\",\n \"type\": \"boolean\",\n \"_index\": 0,\n \"_path\": \"entitlements.primary\",\n \"_annotations\": {\n \"@Primary\": {}\n }\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:entitlements.display\",\n \"name\": \"display\",\n \"type\": \"string\",\n \"_index\": 0,\n \"_path\": \"entitlements.display\"\n }\n ]\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:roles\",\n \"name\": \"roles\",\n \"type\": \"complex\",\n \"multiValued\": true,\n \"_index\": 119,\n \"_path\": \"roles\",\n \"_annotations\": {\n \"@AutoCompact\": {},\n \"@ExclusivePrimary\": {},\n \"@ElementAnnotations\": {\n \"@StateSummary\": {}\n }\n },\n \"subAttributes\": [\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:roles.value\",\n \"name\": \"value\",\n \"type\": \"string\",\n \"_index\": 0,\n \"_path\": \"roles.value\",\n \"_annotations\": {\n \"@Identity\": {}\n }\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:roles.type\",\n \"name\": \"type\",\n \"type\": \"string\",\n \"_index\": 1,\n \"_path\": \"roles.type\",\n \"_annotations\": {\n \"@Identity\": {}\n }\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:roles.primary\",\n \"name\": \"primary\",\n \"type\": \"boolean\",\n \"_index\": 2,\n \"_path\": \"roles.primary\",\n \"_annotations\": {\n \"@Primary\": {}\n }\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:roles.display\",\n \"name\": \"display\",\n \"type\": \"string\",\n \"_index\": 3,\n \"_path\": \"roles.display\"\n }\n ]\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:x509Certificates\",\n \"name\": \"x509Certificates\",\n \"type\": \"complex\",\n \"multiValued\": true,\n \"_index\": 120,\n \"_path\": \"x509Certificates\",\n \"_annotations\": {\n \"@AutoCompact\": {},\n \"@ExclusivePrimary\": {},\n \"@ElementAnnotations\": {\n \"@StateSummary\": {}\n }\n },\n \"subAttributes\": [\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:x509Certificates.value\",\n \"name\": \"value\",\n \"type\": \"binary\",\n \"_index\": 0,\n \"_path\": \"x509Certificates.value\",\n \"_annotations\": {\n \"@Identity\": {}\n }\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:x509Certificates.type\",\n \"name\": \"type\",\n \"type\": \"string\",\n \"_index\": 1,\n \"_path\": \"x509Certificates.type\",\n \"_annotations\": {\n \"@Identity\": {}\n }\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:x509Certificates.primary\",\n \"name\": \"primary\",\n \"type\": \"boolean\",\n \"_index\": 2,\n \"_path\": \"x509Certificates.primary\",\n \"_annotations\": {\n \"@Primary\": {}\n }\n },\n {\n \"id\": \"urn:ietf:params:scim:schemas:core:2.0:User:x509Certificates.display\",\n \"name\": \"display\",\n \"type\": \"string\",\n \"_index\": 3,\n \"_path\": \"x509Certificates.display\"\n }\n ]\n }\n ]\n}PK\x07\x08\xc9.\xc2\xd7\xd9L\x00\x00\xd9L\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00 \x00 \x00static.goUT\x05\x00\x01\x80Cm8// Code generated by statik. DO NOT EDIT.\n\n// Package contains static assets.\npackage assets\n\nvar assets = \"PK\\x03\\x04\\x14\\x00\\x08\\x00\\x00\\x00\\x00\\x00!(\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00'\\x00 \\x00resource_types/group_resource_type.jsonUT\\x05\\x00\\x01\\x80Cm8{\\n \\\"id\\\": \\\"Group\\\",\\n \\\"name\\\": \\\"Group\\\",\\n \\\"endpoint\\\": \\\"/Groups\\\",\\n \\\"schema\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:Group\\\"\\n}PK\\x07\\x08E*\\x91~z\\x00\\x00\\x00z\\x00\\x00\\x00PK\\x03\\x04\\x14\\x00\\x08\\x00\\x00\\x00\\x00\\x00!(\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00&\\x00 \\x00resource_types/user_resource_type.jsonUT\\x05\\x00\\x01\\x80Cm8{\\n \\\"id\\\": \\\"User\\\",\\n \\\"name\\\": \\\"User\\\",\\n \\\"endpoint\\\": \\\"/Users\\\",\\n \\\"schema\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User\\\",\\n \\\"schemaExtensions\\\": [\\n {\\n \\\"schema\\\": \\\"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User\\\",\\n \\\"required\\\": false\\n }\\n ]\\n}PK\\x07\\x08\\x10\\xd6\\x95\\x11\\x05\\x01\\x00\\x00\\x05\\x01\\x00\\x00PK\\x03\\x04\\x14\\x00\\x08\\x00\\x00\\x00\\x00\\x00!(\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x18\\x00 \\x00schemas/core_schema.jsonUT\\x05\\x00\\x01\\x80Cm8{\\n \\\"id\\\": \\\"core\\\",\\n \\\"name\\\": \\\"Core\\\",\\n \\\"description\\\": \\\"Shared attributes for all SCIM resources\\\",\\n \\\"attributes\\\": [\\n {\\n \\\"id\\\": \\\"schemas\\\",\\n \\\"name\\\": \\\"schemas\\\",\\n \\\"type\\\": \\\"reference\\\",\\n \\\"multiValued\\\": true,\\n \\\"required\\\": true,\\n \\\"caseExact\\\": true,\\n \\\"returned\\\": \\\"always\\\",\\n \\\"_index\\\": 0,\\n \\\"_path\\\": \\\"schemas\\\",\\n \\\"_annotations\\\": {\\n \\\"@AutoCompact\\\": {}\\n }\\n },\\n {\\n \\\"id\\\": \\\"id\\\",\\n \\\"name\\\": \\\"id\\\",\\n \\\"type\\\": \\\"string\\\",\\n \\\"caseExact\\\": true,\\n \\\"returned\\\": \\\"always\\\",\\n \\\"mutability\\\": \\\"readOnly\\\",\\n \\\"uniqueness\\\": \\\"global\\\",\\n \\\"_index\\\": 1,\\n \\\"_path\\\": \\\"id\\\",\\n \\\"_annotations\\\": {\\n \\\"@ReadOnly\\\": {\\n \\\"reset\\\": true,\\n \\\"copy\\\": true\\n },\\n \\\"@UUID\\\": {}\\n }\\n },\\n {\\n \\\"id\\\": \\\"externalId\\\",\\n \\\"name\\\": \\\"externalId\\\",\\n \\\"type\\\": \\\"string\\\",\\n \\\"_index\\\": 2,\\n \\\"_path\\\": \\\"externalId\\\"\\n },\\n {\\n \\\"id\\\": \\\"meta\\\",\\n \\\"name\\\": \\\"meta\\\",\\n \\\"type\\\": \\\"complex\\\",\\n \\\"mutability\\\": \\\"readOnly\\\",\\n \\\"_index\\\": 3,\\n \\\"_path\\\": \\\"meta\\\",\\n \\\"subAttributes\\\": [\\n {\\n \\\"id\\\": \\\"meta.resourceType\\\",\\n \\\"name\\\": \\\"resourceType\\\",\\n \\\"type\\\": \\\"string\\\",\\n \\\"caseExact\\\": true,\\n \\\"mutability\\\": \\\"readOnly\\\",\\n \\\"_index\\\": 0,\\n \\\"_path\\\": \\\"meta.resourceType\\\",\\n \\\"_annotations\\\": {\\n \\\"@ReadOnly\\\": {\\n \\\"reset\\\": true,\\n \\\"copy\\\": true\\n }\\n }\\n },\\n {\\n \\\"id\\\": \\\"meta.created\\\",\\n \\\"name\\\": \\\"created\\\",\\n \\\"type\\\": \\\"dateTime\\\",\\n \\\"mutability\\\": \\\"readOnly\\\",\\n \\\"_index\\\": 1,\\n \\\"_path\\\": \\\"meta.created\\\",\\n \\\"_annotations\\\": {\\n \\\"@ReadOnly\\\": {\\n \\\"reset\\\": true,\\n \\\"copy\\\": true\\n }\\n }\\n },\\n {\\n \\\"id\\\": \\\"meta.lastModified\\\",\\n \\\"name\\\": \\\"lastModified\\\",\\n \\\"type\\\": \\\"dateTime\\\",\\n \\\"mutability\\\": \\\"readOnly\\\",\\n \\\"_index\\\": 2,\\n \\\"_path\\\": \\\"meta.lastModified\\\",\\n \\\"_annotations\\\": {\\n \\\"@ReadOnly\\\": {\\n \\\"reset\\\": true,\\n \\\"copy\\\": true\\n }\\n }\\n },\\n {\\n \\\"id\\\": \\\"meta.location\\\",\\n \\\"name\\\": \\\"location\\\",\\n \\\"type\\\": \\\"reference\\\",\\n \\\"mutability\\\": \\\"readOnly\\\",\\n \\\"caseExact\\\": true,\\n \\\"_index\\\": 3,\\n \\\"_path\\\": \\\"meta.location\\\",\\n \\\"_annotations\\\": {\\n \\\"@ReadOnly\\\": {\\n \\\"reset\\\": true,\\n \\\"copy\\\": true\\n }\\n }\\n },\\n {\\n \\\"id\\\": \\\"meta.version\\\",\\n \\\"name\\\": \\\"version\\\",\\n \\\"type\\\": \\\"string\\\",\\n \\\"mutability\\\": \\\"readOnly\\\",\\n \\\"_index\\\": 4,\\n \\\"_path\\\": \\\"meta.version\\\",\\n \\\"_annotations\\\": {\\n \\\"@ReadOnly\\\": {\\n \\\"reset\\\": true,\\n \\\"copy\\\": true\\n }\\n }\\n }\\n ]\\n }\\n ]\\n}PK\\x07\\x08\\xe9\\xbf\\x88(Z\\x0b\\x00\\x00Z\\x0b\\x00\\x00PK\\x03\\x04\\x14\\x00\\x08\\x00\\x00\\x00\\x00\\x00!(\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x19\\x00 \\x00schemas/group_schema.jsonUT\\x05\\x00\\x01\\x80Cm8{\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:Group\\\",\\n \\\"name\\\": \\\"Group\\\",\\n \\\"description\\\": \\\"Defined attributes for the group schema\\\",\\n \\\"attributes\\\": [\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:Group:displayName\\\",\\n \\\"name\\\": \\\"displayName\\\",\\n \\\"type\\\": \\\"string\\\",\\n \\\"_index\\\": 100,\\n \\\"_path\\\": \\\"displayName\\\"\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:Group:members\\\",\\n \\\"name\\\": \\\"members\\\",\\n \\\"type\\\": \\\"complex\\\",\\n \\\"multiValued\\\": true,\\n \\\"subAttributes\\\": [\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:Group:members.value\\\",\\n \\\"name\\\": \\\"value\\\",\\n \\\"type\\\": \\\"string\\\",\\n \\\"mutability\\\": \\\"immutable\\\",\\n \\\"_index\\\": 0,\\n \\\"_path\\\": \\\"members.value\\\",\\n \\\"_annotations\\\":{\\n \\\"@Identity\\\": {}\\n }\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:Group:members.$ref\\\",\\n \\\"name\\\": \\\"$ref\\\",\\n \\\"type\\\": \\\"reference\\\",\\n \\\"mutability\\\": \\\"immutable\\\",\\n \\\"_index\\\": 1,\\n \\\"_path\\\": \\\"members.$ref\\\"\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:Group:members.display\\\",\\n \\\"name\\\": \\\"display\\\",\\n \\\"type\\\": \\\"string\\\",\\n \\\"_index\\\": 2,\\n \\\"_path\\\": \\\"members.display\\\"\\n }\\n ],\\n \\\"_index\\\": 101,\\n \\\"_path\\\": \\\"members\\\",\\n \\\"_annotations\\\": {\\n \\\"@AutoCompact\\\": {},\\n \\\"@ElementAnnotations\\\": {\\n \\\"@StateSummary\\\": {}\\n }\\n }\\n }\\n ]\\n}PK\\x07\\x08\\xbdW;\\xa3\\xd8\\x05\\x00\\x00\\xd8\\x05\\x00\\x00PK\\x03\\x04\\x14\\x00\\x08\\x00\\x00\\x00\\x00\\x00!(\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00-\\x00 \\x00schemas/user_enterprise_extension_schema.jsonUT\\x05\\x00\\x01\\x80Cm8{\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User\\\",\\n \\\"name\\\": \\\"Enterprise User\\\",\\n \\\"description\\\": \\\"Extension attributes for enterprises\\\",\\n \\\"attributes\\\": [\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber\\\",\\n \\\"name\\\": \\\"employeeNumber\\\",\\n \\\"type\\\": \\\"string\\\",\\n \\\"_index\\\": 0,\\n \\\"_path\\\": \\\"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber\\\"\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:costCenter\\\",\\n \\\"name\\\": \\\"costCenter\\\",\\n \\\"type\\\": \\\"string\\\",\\n \\\"_index\\\": 1,\\n \\\"_path\\\": \\\"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:costCenter\\\"\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:organization\\\",\\n \\\"name\\\": \\\"organization\\\",\\n \\\"type\\\": \\\"string\\\",\\n \\\"_index\\\": 2,\\n \\\"_path\\\": \\\"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:organization\\\"\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:division\\\",\\n \\\"name\\\": \\\"division\\\",\\n \\\"type\\\": \\\"string\\\",\\n \\\"_index\\\": 3,\\n \\\"_path\\\": \\\"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:division\\\"\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department\\\",\\n \\\"name\\\": \\\"department\\\",\\n \\\"type\\\": \\\"string\\\",\\n \\\"_index\\\": 4,\\n \\\"_path\\\": \\\"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department\\\"\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:manager\\\",\\n \\\"name\\\": \\\"manager\\\",\\n \\\"type\\\": \\\"complex\\\",\\n \\\"_index\\\": 5,\\n \\\"_path\\\": \\\"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:manager\\\",\\n \\\"_annotations\\\": {\\n \\\"@StateSummary\\\": {}\\n },\\n \\\"subAttributes\\\": [\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:manager.value\\\",\\n \\\"name\\\": \\\"value\\\",\\n \\\"type\\\": \\\"string\\\",\\n \\\"_index\\\": 0,\\n \\\"_path\\\": \\\"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:manager.value\\\"\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:manager.$ref\\\",\\n \\\"name\\\": \\\"$ref\\\",\\n \\\"type\\\": \\\"reference\\\",\\n \\\"_index\\\": 1,\\n \\\"_path\\\": \\\"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:manager.$ref\\\"\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:manager.displayName\\\",\\n \\\"name\\\": \\\"displayName\\\",\\n \\\"type\\\": \\\"string\\\",\\n \\\"_index\\\": 2,\\n \\\"_path\\\": \\\"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:manager.displayName\\\"\\n }\\n ]\\n }\\n ]\\n}PK\\x07\\x08iu\\xb0\\xe1\\x7f\\n\\x00\\x00\\x7f\\n\\x00\\x00PK\\x03\\x04\\x14\\x00\\x08\\x00\\x00\\x00\\x00\\x00!(\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x18\\x00 \\x00schemas/user_schema.jsonUT\\x05\\x00\\x01\\x80Cm8{\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User\\\",\\n \\\"name\\\": \\\"User\\\",\\n \\\"description\\\": \\\"Defined attributes for the user schema\\\",\\n \\\"attributes\\\": [\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:userName\\\",\\n \\\"name\\\": \\\"userName\\\",\\n \\\"type\\\": \\\"string\\\",\\n \\\"required\\\": true,\\n \\\"uniqueness\\\": \\\"server\\\",\\n \\\"_index\\\": 100,\\n \\\"_path\\\": \\\"userName\\\"\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:name\\\",\\n \\\"name\\\": \\\"name\\\",\\n \\\"type\\\": \\\"complex\\\",\\n \\\"_index\\\": 101,\\n \\\"_path\\\": \\\"name\\\",\\n \\\"_annotations\\\": {\\n \\\"@StateSummary\\\": {}\\n },\\n \\\"subAttributes\\\": [\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:name.formatted\\\",\\n \\\"name\\\": \\\"formatted\\\",\\n \\\"type\\\": \\\"string\\\",\\n \\\"_index\\\": 0,\\n \\\"_path\\\": \\\"name.formatted\\\",\\n \\\"_annotations\\\": {\\n \\\"@Identity\\\": {}\\n }\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:name.familyName\\\",\\n \\\"name\\\": \\\"familyName\\\",\\n \\\"type\\\": \\\"string\\\",\\n \\\"_index\\\": 1,\\n \\\"_path\\\": \\\"name.familyName\\\",\\n \\\"_annotations\\\": {\\n \\\"@Identity\\\": {}\\n }\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:name.givenName\\\",\\n \\\"name\\\": \\\"givenName\\\",\\n \\\"type\\\": \\\"string\\\",\\n \\\"_index\\\": 2,\\n \\\"_path\\\": \\\"name.givenName\\\",\\n \\\"_annotations\\\": {\\n \\\"@Identity\\\": {}\\n }\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:name.middleName\\\",\\n \\\"name\\\": \\\"middleName\\\",\\n \\\"type\\\": \\\"string\\\",\\n \\\"_index\\\": 3,\\n \\\"_path\\\": \\\"name.middleName\\\",\\n \\\"_annotations\\\": {\\n \\\"@Identity\\\": {}\\n }\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:name.honorificPrefix\\\",\\n \\\"name\\\": \\\"honorificPrefix\\\",\\n \\\"type\\\": \\\"string\\\",\\n \\\"_index\\\": 4,\\n \\\"_path\\\": \\\"name.honorificPrefix\\\",\\n \\\"_annotations\\\": {\\n \\\"@Identity\\\": {}\\n }\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:name.honorificSuffix\\\",\\n \\\"name\\\": \\\"honorificSuffix\\\",\\n \\\"type\\\": \\\"string\\\",\\n \\\"_index\\\": 5,\\n \\\"_path\\\": \\\"name.honorificSuffix\\\",\\n \\\"_annotations\\\": {\\n \\\"@Identity\\\": {}\\n }\\n }\\n ]\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:displayName\\\",\\n \\\"name\\\": \\\"displayName\\\",\\n \\\"type\\\": \\\"string\\\",\\n \\\"_index\\\": 102,\\n \\\"_path\\\": \\\"displayName\\\"\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:nickName\\\",\\n \\\"name\\\": \\\"nickName\\\",\\n \\\"type\\\": \\\"string\\\",\\n \\\"_index\\\": 103,\\n \\\"_path\\\": \\\"nickName\\\"\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:profileUrl\\\",\\n \\\"name\\\": \\\"profileUrl\\\",\\n \\\"type\\\": \\\"reference\\\",\\n \\\"referenceTypes\\\": [\\n \\\"external\\\"\\n ],\\n \\\"_index\\\": 104,\\n \\\"_path\\\": \\\"profileUrl\\\"\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:title\\\",\\n \\\"name\\\": \\\"title\\\",\\n \\\"type\\\": \\\"string\\\",\\n \\\"_index\\\": 105,\\n \\\"_path\\\": \\\"title\\\"\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:userType\\\",\\n \\\"name\\\": \\\"userType\\\",\\n \\\"type\\\": \\\"string\\\",\\n \\\"canonicalValues\\\": [\\n \\\"Employee\\\",\\n \\\"Intern\\\"\\n ],\\n \\\"_index\\\": 106,\\n \\\"_path\\\": \\\"userType\\\"\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:preferredLanguage\\\",\\n \\\"name\\\": \\\"preferredLanguage\\\",\\n \\\"type\\\": \\\"string\\\",\\n \\\"canonicalValues\\\": [\\n \\\"zh_CN\\\",\\n \\\"en_US\\\"\\n ],\\n \\\"_index\\\": 107,\\n \\\"_path\\\": \\\"preferredLanguage\\\"\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:locale\\\",\\n \\\"name\\\": \\\"locale\\\",\\n \\\"type\\\": \\\"string\\\",\\n \\\"canonicalValues\\\": [\\n \\\"en_US\\\",\\n \\\"zh_CN\\\"\\n ],\\n \\\"_index\\\": 108,\\n \\\"_path\\\": \\\"locale\\\"\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:timezone\\\",\\n \\\"name\\\": \\\"timezone\\\",\\n \\\"type\\\": \\\"string\\\",\\n \\\"canonicalValues\\\": [\\n \\\"Asia/Shanghai\\\",\\n \\\"Asia/Beijing\\\",\\n \\\"America/New_York\\\",\\n \\\"America/Toronto\\\"\\n ],\\n \\\"_index\\\": 109,\\n \\\"_path\\\": \\\"timezone\\\"\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:active\\\",\\n \\\"name\\\": \\\"active\\\",\\n \\\"type\\\": \\\"boolean\\\",\\n \\\"_index\\\": 110,\\n \\\"_path\\\": \\\"active\\\"\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:password\\\",\\n \\\"name\\\": \\\"password\\\",\\n \\\"type\\\": \\\"string\\\",\\n \\\"mutability\\\": \\\"writeOnly\\\",\\n \\\"returned\\\": \\\"never\\\",\\n \\\"_index\\\": 111,\\n \\\"_path\\\": \\\"password\\\",\\n \\\"_annotations\\\": {\\n \\\"@BCrypt\\\": {\\n \\\"cost\\\": 10\\n }\\n }\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:emails\\\",\\n \\\"name\\\": \\\"emails\\\",\\n \\\"type\\\": \\\"complex\\\",\\n \\\"multiValued\\\": true,\\n \\\"required\\\": true,\\n \\\"_index\\\": 112,\\n \\\"_path\\\": \\\"emails\\\",\\n \\\"_annotations\\\": {\\n \\\"@AutoCompact\\\": {},\\n \\\"@ExclusivePrimary\\\": {},\\n \\\"@ElementAnnotations\\\": {\\n \\\"@StateSummary\\\": {}\\n }\\n },\\n \\\"subAttributes\\\": [\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:emails.value\\\",\\n \\\"name\\\": \\\"value\\\",\\n \\\"type\\\": \\\"string\\\",\\n \\\"_index\\\": 0,\\n \\\"_path\\\": \\\"emails.value\\\",\\n \\\"_annotations\\\": {\\n \\\"@Identity\\\": {}\\n }\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:emails.type\\\",\\n \\\"name\\\": \\\"type\\\",\\n \\\"type\\\": \\\"string\\\",\\n \\\"canonicalValues\\\": [\\n \\\"work\\\",\\n \\\"home\\\",\\n \\\"other\\\"\\n ],\\n \\\"_index\\\": 1,\\n \\\"_path\\\": \\\"emails.type\\\",\\n \\\"_annotations\\\": {\\n \\\"@Identity\\\": {}\\n }\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:emails.primary\\\",\\n \\\"name\\\": \\\"primary\\\",\\n \\\"type\\\": \\\"boolean\\\",\\n \\\"_index\\\": 2,\\n \\\"_path\\\": \\\"emails.primary\\\",\\n \\\"_annotations\\\": {\\n \\\"@Primary\\\": {}\\n }\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:emails.display\\\",\\n \\\"name\\\": \\\"display\\\",\\n \\\"type\\\": \\\"string\\\",\\n \\\"_index\\\": 3,\\n \\\"_path\\\": \\\"emails.display\\\"\\n }\\n ]\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:phoneNumbers\\\",\\n \\\"name\\\": \\\"phoneNumbers\\\",\\n \\\"type\\\": \\\"complex\\\",\\n \\\"multiValued\\\": true,\\n \\\"_index\\\": 113,\\n \\\"_path\\\": \\\"phoneNumbers\\\",\\n \\\"_annotations\\\": {\\n \\\"@AutoCompact\\\": {},\\n \\\"@ExclusivePrimary\\\": {},\\n \\\"@ElementAnnotations\\\": {\\n \\\"@StateSummary\\\": {}\\n }\\n },\\n \\\"subAttributes\\\": [\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:phoneNumbers.value\\\",\\n \\\"name\\\": \\\"value\\\",\\n \\\"type\\\": \\\"string\\\",\\n \\\"_index\\\": 0,\\n \\\"_path\\\": \\\"phoneNumbers.value\\\",\\n \\\"_annotations\\\": {\\n \\\"@Identity\\\": {}\\n }\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:phoneNumbers.type\\\",\\n \\\"name\\\": \\\"type\\\",\\n \\\"type\\\": \\\"string\\\",\\n \\\"canonicalValues\\\": [\\n \\\"work\\\",\\n \\\"home\\\",\\n \\\"mobile\\\",\\n \\\"fax\\\",\\n \\\"other\\\"\\n ],\\n \\\"_index\\\": 1,\\n \\\"_path\\\": \\\"phoneNumbers.type\\\",\\n \\\"_annotations\\\": {\\n \\\"@Identity\\\": {}\\n }\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:phoneNumbers.primary\\\",\\n \\\"name\\\": \\\"primary\\\",\\n \\\"type\\\": \\\"boolean\\\",\\n \\\"_index\\\": 2,\\n \\\"_path\\\": \\\"phoneNumbers.primary\\\",\\n \\\"_annotations\\\": {\\n \\\"@Primary\\\": {}\\n }\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:phoneNumbers.display\\\",\\n \\\"name\\\": \\\"display\\\",\\n \\\"type\\\": \\\"string\\\",\\n \\\"_index\\\": 3,\\n \\\"_path\\\": \\\"phoneNumbers.display\\\"\\n }\\n ]\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:ims\\\",\\n \\\"name\\\": \\\"ims\\\",\\n \\\"type\\\": \\\"complex\\\",\\n \\\"multiValued\\\": true,\\n \\\"_index\\\": 114,\\n \\\"_path\\\": \\\"ims\\\",\\n \\\"_annotations\\\": {\\n \\\"@AutoCompact\\\": {},\\n \\\"@ExclusivePrimary\\\": {},\\n \\\"@ElementAnnotations\\\": {\\n \\\"@StateSummary\\\": {}\\n }\\n },\\n \\\"subAttributes\\\": [\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:ims.value\\\",\\n \\\"name\\\": \\\"value\\\",\\n \\\"type\\\": \\\"string\\\",\\n \\\"_index\\\": 0,\\n \\\"_path\\\": \\\"ims.value\\\",\\n \\\"_annotations\\\": {\\n \\\"@Identity\\\": {}\\n }\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:ims.type\\\",\\n \\\"name\\\": \\\"type\\\",\\n \\\"type\\\": \\\"string\\\",\\n \\\"canonicalValues\\\": [\\n \\\"skype\\\",\\n \\\"qq\\\",\\n \\\"wechat\\\",\\n \\\"weibo\\\",\\n \\\"other\\\"\\n ],\\n \\\"_index\\\": 1,\\n \\\"_path\\\": \\\"ims.type\\\",\\n \\\"_annotations\\\": {\\n \\\"@Identity\\\": {}\\n }\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:ims.primary\\\",\\n \\\"name\\\": \\\"primary\\\",\\n \\\"type\\\": \\\"boolean\\\",\\n \\\"_index\\\": 2,\\n \\\"_path\\\": \\\"ims.primary\\\",\\n \\\"_annotations\\\": {\\n \\\"@Primary\\\": {}\\n }\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:ims.display\\\",\\n \\\"name\\\": \\\"display\\\",\\n \\\"type\\\": \\\"string\\\",\\n \\\"_index\\\": 3,\\n \\\"_path\\\": \\\"ims.display\\\"\\n }\\n ]\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:photos\\\",\\n \\\"name\\\": \\\"photos\\\",\\n \\\"type\\\": \\\"complex\\\",\\n \\\"multiValued\\\": true,\\n \\\"_index\\\": 115,\\n \\\"_path\\\": \\\"photos\\\",\\n \\\"_annotations\\\": {\\n \\\"@AutoCompact\\\": {},\\n \\\"@ExclusivePrimary\\\": {},\\n \\\"@ElementAnnotations\\\": {\\n \\\"@StateSummary\\\": {}\\n }\\n },\\n \\\"subAttributes\\\": [\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:photos.value\\\",\\n \\\"name\\\": \\\"value\\\",\\n \\\"type\\\": \\\"reference\\\",\\n \\\"referenceTypes\\\": [\\n \\\"external\\\"\\n ],\\n \\\"_index\\\": 0,\\n \\\"_path\\\": \\\"photos.value\\\",\\n \\\"_annotations\\\": {\\n \\\"@Identity\\\": {}\\n }\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:photos.type\\\",\\n \\\"name\\\": \\\"type\\\",\\n \\\"type\\\": \\\"string\\\",\\n \\\"canonicalValues\\\": [\\n \\\"photo\\\",\\n \\\"thumbnail\\\"\\n ],\\n \\\"_index\\\": 1,\\n \\\"_path\\\": \\\"photos.type\\\",\\n \\\"_annotations\\\": {\\n \\\"@Identity\\\": {}\\n }\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:photos.primary\\\",\\n \\\"name\\\": \\\"primary\\\",\\n \\\"type\\\": \\\"boolean\\\",\\n \\\"_index\\\": 2,\\n \\\"_path\\\": \\\"photos.primary\\\",\\n \\\"_annotations\\\": {\\n \\\"@Primary\\\": {}\\n }\\n }\\n ]\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:addresses\\\",\\n \\\"name\\\": \\\"addresses\\\",\\n \\\"type\\\": \\\"complex\\\",\\n \\\"multiValued\\\": true,\\n \\\"_index\\\": 116,\\n \\\"_path\\\": \\\"addresses\\\",\\n \\\"_annotations\\\": {\\n \\\"@AutoCompact\\\": {},\\n \\\"@ExclusivePrimary\\\": {},\\n \\\"@ElementAnnotations\\\": {\\n \\\"@StateSummary\\\": {}\\n }\\n },\\n \\\"subAttributes\\\": [\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:addresses.formatted\\\",\\n \\\"name\\\": \\\"formatted\\\",\\n \\\"type\\\": \\\"string\\\",\\n \\\"_index\\\": 0,\\n \\\"_path\\\": \\\"photos.formatted\\\"\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:addresses.streetAddress\\\",\\n \\\"name\\\": \\\"streetAddress\\\",\\n \\\"type\\\": \\\"string\\\",\\n \\\"_index\\\": 1,\\n \\\"_path\\\": \\\"photos.streetAddress\\\",\\n \\\"_annotations\\\": {\\n \\\"@Identity\\\": {}\\n }\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:addresses.locality\\\",\\n \\\"name\\\": \\\"locality\\\",\\n \\\"type\\\": \\\"string\\\",\\n \\\"_index\\\": 2,\\n \\\"_path\\\": \\\"photos.locality\\\",\\n \\\"_annotations\\\": {\\n \\\"@Identity\\\": {}\\n }\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:addresses.region\\\",\\n \\\"name\\\": \\\"region\\\",\\n \\\"type\\\": \\\"string\\\",\\n \\\"_index\\\": 3,\\n \\\"_path\\\": \\\"photos.region\\\",\\n \\\"_annotations\\\": {\\n \\\"@Identity\\\": {}\\n }\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:addresses.postalCode\\\",\\n \\\"name\\\": \\\"postalCode\\\",\\n \\\"type\\\": \\\"string\\\",\\n \\\"_index\\\": 4,\\n \\\"_path\\\": \\\"photos.postalCode\\\",\\n \\\"_annotations\\\": {\\n \\\"@Identity\\\": {}\\n }\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:addresses.country\\\",\\n \\\"name\\\": \\\"country\\\",\\n \\\"type\\\": \\\"string\\\",\\n \\\"_index\\\": 5,\\n \\\"_path\\\": \\\"photos.country\\\",\\n \\\"_annotations\\\": {\\n \\\"@Identity\\\": {}\\n }\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:addresses.type\\\",\\n \\\"name\\\": \\\"type\\\",\\n \\\"type\\\": \\\"string\\\",\\n \\\"canonicalValues\\\": [\\n \\\"work\\\",\\n \\\"home\\\",\\n \\\"id\\\",\\n \\\"driver\\\",\\n \\\"other\\\"\\n ],\\n \\\"_index\\\": 6,\\n \\\"_path\\\": \\\"photos.type\\\",\\n \\\"_annotations\\\": {\\n \\\"@Identity\\\": {}\\n }\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:addresses.primary\\\",\\n \\\"name\\\": \\\"primary\\\",\\n \\\"type\\\": \\\"boolean\\\",\\n \\\"_index\\\": 7,\\n \\\"_path\\\": \\\"photos.primary\\\",\\n \\\"_annotations\\\": {\\n \\\"@Primary\\\": {}\\n }\\n }\\n ]\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:groups\\\",\\n \\\"name\\\": \\\"groups\\\",\\n \\\"type\\\": \\\"complex\\\",\\n \\\"multiValued\\\": true,\\n \\\"mutability\\\": \\\"readOnly\\\",\\n \\\"_index\\\": 117,\\n \\\"_path\\\": \\\"groups\\\",\\n \\\"_annotations\\\": {\\n \\\"@ReadOnly\\\": {\\n \\\"reset\\\": true,\\n \\\"copy\\\": true\\n }\\n },\\n \\\"subAttributes\\\": [\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:groups.value\\\",\\n \\\"name\\\": \\\"value\\\",\\n \\\"type\\\": \\\"string\\\",\\n \\\"mutability\\\": \\\"readOnly\\\",\\n \\\"_index\\\": 0,\\n \\\"_path\\\": \\\"groups.value\\\"\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:groups.$ref\\\",\\n \\\"name\\\": \\\"$ref\\\",\\n \\\"type\\\": \\\"reference\\\",\\n \\\"mutability\\\": \\\"readOnly\\\",\\n \\\"_index\\\": 1,\\n \\\"_path\\\": \\\"groups.$ref\\\"\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:groups.type\\\",\\n \\\"name\\\": \\\"type\\\",\\n \\\"type\\\": \\\"string\\\",\\n \\\"mutability\\\": \\\"readOnly\\\",\\n \\\"canonicalValues\\\": [\\n \\\"direct\\\",\\n \\\"indirect\\\"\\n ],\\n \\\"_index\\\": 2,\\n \\\"_path\\\": \\\"groups.type\\\"\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:groups.display\\\",\\n \\\"name\\\": \\\"display\\\",\\n \\\"type\\\": \\\"string\\\",\\n \\\"mutability\\\": \\\"readOnly\\\",\\n \\\"_index\\\": 3,\\n \\\"_path\\\": \\\"groups.display\\\"\\n }\\n ]\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:entitlements\\\",\\n \\\"name\\\": \\\"entitlements\\\",\\n \\\"type\\\": \\\"complex\\\",\\n \\\"multiValued\\\": true,\\n \\\"_index\\\": 118,\\n \\\"_path\\\": \\\"entitlements\\\",\\n \\\"_annotations\\\": {\\n \\\"@AutoCompact\\\": {},\\n \\\"@ExclusivePrimary\\\": {},\\n \\\"@ElementAnnotations\\\": {\\n \\\"@StateSummary\\\": {}\\n }\\n },\\n \\\"subAttributes\\\": [\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:entitlements.value\\\",\\n \\\"name\\\": \\\"value\\\",\\n \\\"type\\\": \\\"string\\\",\\n \\\"_index\\\": 0,\\n \\\"_path\\\": \\\"entitlements.value\\\",\\n \\\"_annotations\\\": {\\n \\\"@Identity\\\": {}\\n }\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:entitlements.type\\\",\\n \\\"name\\\": \\\"type\\\",\\n \\\"type\\\": \\\"string\\\",\\n \\\"_index\\\": 0,\\n \\\"_path\\\": \\\"entitlements.type\\\",\\n \\\"_annotations\\\": {\\n \\\"@Identity\\\": {}\\n }\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:entitlements.primary\\\",\\n \\\"name\\\": \\\"primary\\\",\\n \\\"type\\\": \\\"boolean\\\",\\n \\\"_index\\\": 0,\\n \\\"_path\\\": \\\"entitlements.primary\\\",\\n \\\"_annotations\\\": {\\n \\\"@Primary\\\": {}\\n }\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:entitlements.display\\\",\\n \\\"name\\\": \\\"display\\\",\\n \\\"type\\\": \\\"string\\\",\\n \\\"_index\\\": 0,\\n \\\"_path\\\": \\\"entitlements.display\\\"\\n }\\n ]\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:roles\\\",\\n \\\"name\\\": \\\"roles\\\",\\n \\\"type\\\": \\\"complex\\\",\\n \\\"multiValued\\\": true,\\n \\\"_index\\\": 119,\\n \\\"_path\\\": \\\"roles\\\",\\n \\\"_annotations\\\": {\\n \\\"@AutoCompact\\\": {},\\n \\\"@ExclusivePrimary\\\": {},\\n \\\"@ElementAnnotations\\\": {\\n \\\"@StateSummary\\\": {}\\n }\\n },\\n \\\"subAttributes\\\": [\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:roles.value\\\",\\n \\\"name\\\": \\\"value\\\",\\n \\\"type\\\": \\\"string\\\",\\n \\\"_index\\\": 0,\\n \\\"_path\\\": \\\"roles.value\\\",\\n \\\"_annotations\\\": {\\n \\\"@Identity\\\": {}\\n }\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:roles.type\\\",\\n \\\"name\\\": \\\"type\\\",\\n \\\"type\\\": \\\"string\\\",\\n \\\"_index\\\": 1,\\n \\\"_path\\\": \\\"roles.type\\\",\\n \\\"_annotations\\\": {\\n \\\"@Identity\\\": {}\\n }\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:roles.primary\\\",\\n \\\"name\\\": \\\"primary\\\",\\n \\\"type\\\": \\\"boolean\\\",\\n \\\"_index\\\": 2,\\n \\\"_path\\\": \\\"roles.primary\\\",\\n \\\"_annotations\\\": {\\n \\\"@Primary\\\": {}\\n }\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:roles.display\\\",\\n \\\"name\\\": \\\"display\\\",\\n \\\"type\\\": \\\"string\\\",\\n \\\"_index\\\": 3,\\n \\\"_path\\\": \\\"roles.display\\\"\\n }\\n ]\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:x509Certificates\\\",\\n \\\"name\\\": \\\"x509Certificates\\\",\\n \\\"type\\\": \\\"complex\\\",\\n \\\"multiValued\\\": true,\\n \\\"_index\\\": 120,\\n \\\"_path\\\": \\\"x509Certificates\\\",\\n \\\"_annotations\\\": {\\n \\\"@AutoCompact\\\": {},\\n \\\"@ExclusivePrimary\\\": {},\\n \\\"@ElementAnnotations\\\": {\\n \\\"@StateSummary\\\": {}\\n }\\n },\\n \\\"subAttributes\\\": [\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:x509Certificates.value\\\",\\n \\\"name\\\": \\\"value\\\",\\n \\\"type\\\": \\\"binary\\\",\\n \\\"_index\\\": 0,\\n \\\"_path\\\": \\\"x509Certificates.value\\\",\\n \\\"_annotations\\\": {\\n \\\"@Identity\\\": {}\\n }\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:x509Certificates.type\\\",\\n \\\"name\\\": \\\"type\\\",\\n \\\"type\\\": \\\"string\\\",\\n \\\"_index\\\": 1,\\n \\\"_path\\\": \\\"x509Certificates.type\\\",\\n \\\"_annotations\\\": {\\n \\\"@Identity\\\": {}\\n }\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:x509Certificates.primary\\\",\\n \\\"name\\\": \\\"primary\\\",\\n \\\"type\\\": \\\"boolean\\\",\\n \\\"_index\\\": 2,\\n \\\"_path\\\": \\\"x509Certificates.primary\\\",\\n \\\"_annotations\\\": {\\n \\\"@Primary\\\": {}\\n }\\n },\\n {\\n \\\"id\\\": \\\"urn:ietf:params:scim:schemas:core:2.0:User:x509Certificates.display\\\",\\n \\\"name\\\": \\\"display\\\",\\n \\\"type\\\": \\\"string\\\",\\n \\\"_index\\\": 3,\\n \\\"_path\\\": \\\"x509Certificates.display\\\"\\n }\\n ]\\n }\\n ]\\n}PK\\x07\\x08\\xc9.\\xc2\\xd7\\xd9L\\x00\\x00\\xd9L\\x00\\x00PK\\x01\\x02\\x14\\x03\\x14\\x00\\x08\\x00\\x00\\x00\\x00\\x00!(E*\\x91~z\\x00\\x00\\x00z\\x00\\x00\\x00'\\x00 \\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\xa4\\x81\\x00\\x00\\x00\\x00resource_types/group_resource_type.jsonUT\\x05\\x00\\x01\\x80Cm8PK\\x01\\x02\\x14\\x03\\x14\\x00\\x08\\x00\\x00\\x00\\x00\\x00!(\\x10\\xd6\\x95\\x11\\x05\\x01\\x00\\x00\\x05\\x01\\x00\\x00&\\x00 \\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\xa4\\x81\\xd8\\x00\\x00\\x00resource_types/user_resource_type.jsonUT\\x05\\x00\\x01\\x80Cm8PK\\x01\\x02\\x14\\x03\\x14\\x00\\x08\\x00\\x00\\x00\\x00\\x00!(\\xe9\\xbf\\x88(Z\\x0b\\x00\\x00Z\\x0b\\x00\\x00\\x18\\x00 \\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\xa4\\x81:\\x02\\x00\\x00schemas/core_schema.jsonUT\\x05\\x00\\x01\\x80Cm8PK\\x01\\x02\\x14\\x03\\x14\\x00\\x08\\x00\\x00\\x00\\x00\\x00!(\\xbdW;\\xa3\\xd8\\x05\\x00\\x00\\xd8\\x05\\x00\\x00\\x19\\x00 \\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\xa4\\x81\\xe3\\x0d\\x00\\x00schemas/group_schema.jsonUT\\x05\\x00\\x01\\x80Cm8PK\\x01\\x02\\x14\\x03\\x14\\x00\\x08\\x00\\x00\\x00\\x00\\x00!(iu\\xb0\\xe1\\x7f\\n\\x00\\x00\\x7f\\n\\x00\\x00-\\x00 \\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\xa4\\x81\\x0b\\x14\\x00\\x00schemas/user_enterprise_extension_schema.jsonUT\\x05\\x00\\x01\\x80Cm8PK\\x01\\x02\\x14\\x03\\x14\\x00\\x08\\x00\\x00\\x00\\x00\\x00!(\\xc9.\\xc2\\xd7\\xd9L\\x00\\x00\\xd9L\\x00\\x00\\x18\\x00 \\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\xa4\\x81\\xee\\x1e\\x00\\x00schemas/user_schema.jsonUT\\x05\\x00\\x01\\x80Cm8PK\\x05\\x06\\x00\\x00\\x00\\x00\\x06\\x00\\x06\\x00\\x0d\\x02\\x00\\x00\\x16l\\x00\\x00\\x00\\x00\"\nPK\x07\x08\xd5`5\xf6F\x81\x00\x00F\x81\x00\x00PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(E*\x91~z\x00\x00\x00z\x00\x00\x00'\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\x00\x00\x00\x00resource_types/group_resource_type.jsonUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\x10\xd6\x95\x11\x05\x01\x00\x00\x05\x01\x00\x00&\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xd8\x00\x00\x00resource_types/user_resource_type.jsonUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\xe9\xbf\x88(Z\x0b\x00\x00Z\x0b\x00\x00\x18\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81:\x02\x00\x00schemas/core_schema.jsonUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\xbdW;\xa3\xd8\x05\x00\x00\xd8\x05\x00\x00\x19\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xe3\x0d\x00\x00schemas/group_schema.jsonUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(iu\xb0\xe1\x7f\n\x00\x00\x7f\n\x00\x00-\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\x0b\x14\x00\x00schemas/user_enterprise_extension_schema.jsonUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\xc9.\xc2\xd7\xd9L\x00\x00\xd9L\x00\x00\x18\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xee\x1e\x00\x00schemas/user_schema.jsonUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\xd5`5\xf6F\x81\x00\x00F\x81\x00\x00 \x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\x80\x81\x16l\x00\x00static.goUT\x05\x00\x01\x80Cm8PK\x05\x06\x00\x00\x00\x00\x07\x00\x07\x00M\x02\x00\x00\x9c\xed\x00\x00\x00\x00" diff --git a/system/scim/gen_response.go b/system/scim/gen_response.go new file mode 100644 index 000000000..5c985ccaf --- /dev/null +++ b/system/scim/gen_response.go @@ -0,0 +1,24 @@ +package scim + +import ( + "github.com/cortezaproject/corteza-server/system/types" + "time" +) + +type ( + metaResponse struct { + ResourceType string `json:"resourceType"` + Created time.Time `json:"created"` + LastModified *time.Time `json:"lastModified,omitempty"` + } +) + +func newUserMetaResponse(u *types.User) *metaResponse { + rsp := &metaResponse{ + ResourceType: "User", + Created: u.CreatedAt, + LastModified: u.UpdatedAt, + } + + return rsp +} diff --git a/system/scim/http.go b/system/scim/http.go new file mode 100644 index 000000000..5e7d908e2 --- /dev/null +++ b/system/scim/http.go @@ -0,0 +1,14 @@ +package scim + +import ( + "encoding/json" + "go.uber.org/zap" + "net/http" +) + +func send(w http.ResponseWriter, payload interface{}) { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(payload); err != nil { + log.Error("could not encode payload", zap.Error(err)) + } +} diff --git a/system/scim/routes.go b/system/scim/routes.go new file mode 100644 index 000000000..fa9093827 --- /dev/null +++ b/system/scim/routes.go @@ -0,0 +1,54 @@ +package scim + +import ( + "github.com/cortezaproject/corteza-server/pkg/options" + "github.com/cortezaproject/corteza-server/system/scim/assets" + "github.com/cortezaproject/corteza-server/system/service" + "github.com/go-chi/chi" + "github.com/goware/statik/fs" + "go.uber.org/zap" + "net/http" +) + +var ( + embedded http.FileSystem + log = zap.NewNop() +) + +func init() { + var err error + embedded, err = fs.New(assets.Asset) + if err != nil { + panic(err) + } +} + +func Guard(opt options.SCIMOpt) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + // temp authorization mechanism so we do not have to + // pre-create users and generate their auth tokens + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authPrefix := "Bearer " + authHeader := r.Header.Get("Authorization") + if (len(authPrefix)+len(opt.Secret)) == len(authHeader) && opt.Secret == authHeader[len(authPrefix):] { + // all good, auth header matches the secret + next.ServeHTTP(w, r) + return + } + + http.Error(w, "Unauthorized", http.StatusForbidden) + + }) + } +} + +func Routes(r chi.Router) { + uh := &usersHandler{svc: service.DefaultUser} + + r.Route("/Users", func(r chi.Router) { + r.Get("/{id}", uh.get) + r.Post("/", uh.create) + r.Put("/{id}", uh.replace) + r.Delete("/{id}", uh.delete) + }) +} diff --git a/system/scim/user_handler.go b/system/scim/user_handler.go new file mode 100644 index 000000000..057793da1 --- /dev/null +++ b/system/scim/user_handler.go @@ -0,0 +1,144 @@ +package scim + +import ( + "context" + "fmt" + "github.com/cortezaproject/corteza-server/pkg/api" + "github.com/cortezaproject/corteza-server/pkg/auth" + "github.com/cortezaproject/corteza-server/pkg/errors" + "github.com/cortezaproject/corteza-server/system/service" + "github.com/cortezaproject/corteza-server/system/types" + "github.com/go-chi/chi" + "io" + "net/http" + "strconv" +) + +type ( + usersHandler struct { + svc service.UserService + } +) + +func (h usersHandler) get(w http.ResponseWriter, r *http.Request) { + var ( + id, _ = strconv.ParseUint(chi.URLParam(r, "id"), 10, 64) + ctx = auth.SetSuperUserContext(r.Context()) + svc = h.svc.With(ctx) + ) + + if id == 0 { + http.Error(w, "invalid user id", http.StatusBadRequest) + return + } + + if u, err := svc.FindByID(id); err != nil { + errors.ServeHTTP(w, r, err, !api.DebugFromContext(r.Context())) + return + } else { + send(w, newUserResourceResponse(u)) + } + + w.WriteHeader(200) +} + +func (h usersHandler) create(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + + var ( + ctx = auth.SetSuperUserContext(r.Context()) + ) + + if u, err := h.createFromJSON(ctx, r.Body); err != nil { + errors.ServeHTTP(w, r, err, !api.DebugFromContext(r.Context())) + } else { + w.WriteHeader(http.StatusCreated) + send(w, newUserResourceResponse(u)) + } +} + +func (h usersHandler) createFromJSON(ctx context.Context, j io.Reader) (u *types.User, err error) { + var ( + svc = h.svc.With(ctx) + payload = &userResourceRequest{} + ) + + if err = payload.decodeJSON(j); err != nil { + return + } + + // do we need to upsert? + if email := payload.Emails.getFirst(); email != "" { + u, err = svc.FindByEmail(email) + if err != nil && !errors.Is(err, service.UserErrNotFound()) { + return + } + } + + if u == nil || !u.Valid() { + // in case when we did not find a valid user, + // start from blank + u = &types.User{} + } + + payload.applyTo(u) + + if u.ID > 0 { + return svc.Update(u) + } else { + return svc.Create(u) + } +} + +func (h usersHandler) replace(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + + var ( + ctx = auth.SetSuperUserContext(r.Context()) + userID, _ = strconv.ParseUint(chi.URLParam(r, "id"), 10, 64) + ) + + if u, err := h.updateFromJSON(ctx, userID, r.Body); err != nil { + errors.ServeHTTP(w, r, err, !api.DebugFromContext(r.Context())) + } else { + w.WriteHeader(http.StatusOK) + send(w, newUserResourceResponse(u)) + } +} + +func (h usersHandler) updateFromJSON(ctx context.Context, id uint64, j io.Reader) (u *types.User, err error) { + var ( + svc = h.svc.With(ctx) + payload = &userResourceRequest{} + ) + + if u, err = svc.FindByID(id); err != nil { + return + } + + if u == nil || !u.Valid() { + return nil, fmt.Errorf("refusing to update invalid user") + } + + if err = payload.decodeJSON(j); err != nil { + return + } + + payload.applyTo(u) + + return h.svc.With(ctx).Update(u) +} + +func (h usersHandler) delete(w http.ResponseWriter, r *http.Request) { + var ( + ctx = auth.SetSuperUserContext(r.Context()) + userID, _ = strconv.ParseUint(chi.URLParam(r, "id"), 10, 64) + svc = h.svc.With(ctx) + ) + + if err := svc.Delete(userID); err != nil { + send(w, err) + } else { + w.WriteHeader(http.StatusNoContent) + } +} diff --git a/system/scim/user_payloads.go b/system/scim/user_payloads.go new file mode 100644 index 000000000..2b94f2f91 --- /dev/null +++ b/system/scim/user_payloads.go @@ -0,0 +1,115 @@ +package scim + +import ( + "encoding/json" + "fmt" + "github.com/cortezaproject/corteza-server/pkg/handle" + "github.com/cortezaproject/corteza-server/system/types" + "io" + "strconv" +) + +const ( + urnUser = "urn:ietf:params:scim:schemas:core:2.0:User" + userLabel_SCIM_externalId = "SCIM_externalId" +) + +type ( + emailResponse struct { + Value string `json:"value"` + Primary bool `json:"primary,omitempty"` + } + + emailsResponse []*emailResponse + + userNameResponse struct { + Formatted string `json:"formatted"` + } + + userResourceResponse struct { + Schemas []string `json:"schemas"` + Meta *metaResponse `json:"meta,omitempty"` + ID string `json:"id,omitempty"` + ExternalId string `json:"externalId,omitempty"` + UserName string `json:"userName,omitempty"` + NickName string `json:"nickName,omitempty"` + Name *userNameResponse `json:"displayName"` + Emails emailsResponse `json:"emails,omitempty"` + } + + userResourceRequest struct { + Schemas []string `json:"schemas"` + Meta *metaResponse `json:"meta,omitempty"` + ExternalId *string `json:"externalId,omitempty"` + UserName *string `json:"userName,omitempty"` + NickName *string `json:"nickName,omitempty"` + Password *string `json:"password,omitempty"` + Name *userNameResponse `json:"name"` + Emails emailsResponse `json:"emails,omitempty"` + } +) + +func newUserResourceResponse(u *types.User) *userResourceResponse { + rsp := &userResourceResponse{ + Schemas: []string{urnUser}, + Meta: newUserMetaResponse(u), + ID: strconv.FormatUint(u.ID, 10), + ExternalId: u.Labels[userLabel_SCIM_externalId], + UserName: u.Username, + NickName: u.Handle, + Emails: emailsResponse{{u.Email, true}}, + } + + if u.Name != "" { + rsp.Name = &userNameResponse{Formatted: u.Name} + } + + return rsp +} + +// returns first (primary) email +func (ee emailsResponse) getFirst() string { + if len(ee) == 0 { + return "" + } + + var match int + for i, e := range ee { + if e.Primary { + match = i + break + } + } + + return ee[match].Value +} + +func (req *userResourceRequest) decodeJSON(r io.Reader) error { + if err := json.NewDecoder(r).Decode(req); err != nil { + return fmt.Errorf("could not decode user payload: %w", err) + } + + return nil +} + +func (req *userResourceRequest) applyTo(u *types.User) { + if v := req.Emails.getFirst(); len(v) > 0 { + u.Email = v + } + + if req.Name != nil { + u.Name = req.Name.Formatted + } + + if req.UserName != nil { + u.Username = *req.UserName + } + + if req.NickName != nil && handle.IsValid(*req.NickName) { + u.Handle = *req.NickName + } + + if req.ExternalId != nil { + u.SetLabel("SCIM_externalId", *req.ExternalId) + } +} diff --git a/tests/system/main_test.go b/tests/system/main_test.go index 03fc280b3..b8ebb69a4 100644 --- a/tests/system/main_test.go +++ b/tests/system/main_test.go @@ -78,7 +78,6 @@ func InitTestApp() { eventbus.Set(eventBus) return nil }) - } if r == nil { diff --git a/tests/system/scim_test.go b/tests/system/scim_test.go new file mode 100644 index 000000000..dce3db37c --- /dev/null +++ b/tests/system/scim_test.go @@ -0,0 +1,129 @@ +package system + +import ( + "context" + "fmt" + "github.com/cortezaproject/corteza-server/pkg/api/server" + "github.com/cortezaproject/corteza-server/pkg/logger" + "github.com/cortezaproject/corteza-server/store" + "github.com/cortezaproject/corteza-server/system/scim" + "github.com/cortezaproject/corteza-server/system/service" + "github.com/cortezaproject/corteza-server/tests/helpers" + "github.com/go-chi/chi" + "github.com/steinfletcher/apitest" + jsonpath "github.com/steinfletcher/apitest-jsonpath" + "net/http" + "testing" +) + +var ( + scimRoutes chi.Router +) + +// apitest basics, initialize, set handler, add auth +func (h helper) scimApiInit() *apitest.APITest { + InitTestApp() + + if scimRoutes == nil { + scimRoutes = chi.NewRouter() + scimRoutes.Use(server.BaseMiddleware(false, logger.Default())...) + scim.Routes(scimRoutes) + } + + return apitest. + New(). + Handler(scimRoutes) +} + +func TestScimUserGet(t *testing.T) { + h := newHelper(t) + h.clearUsers() + + u := h.createUserWithEmail(h.randEmail()) + + h.scimApiInit(). + Get(fmt.Sprintf("/Users/%d", u.ID)). + Expect(t). + Status(http.StatusOK). + Assert(helpers.AssertNoErrors). + Assert(jsonpath.Contains(`$.schemas`, "urn:ietf:params:scim:schemas:core:2.0:User")). + Assert(jsonpath.Equal(`$.id`, fmt.Sprintf("%d", u.ID))). + End() +} + +func TestScimUserCreate(t *testing.T) { + h := newHelper(t) + h.clearUsers() + + h.scimApiInit(). + Debug(). + Post("/Users"). + JSON(`{ + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:User" + ], + "userName": "foo", + "nickName": "baz", + "emails": [ + { + "value": "foo@bar.com", + "primary": true + }, + { + "value": "bar@foo.com" + } + ] +}`). + Expect(t). + Status(http.StatusCreated). + Assert(helpers.AssertNoErrors). + End() + + u, err := store.LookupUserByEmail(context.Background(), service.DefaultStore, "foo@bar.com") + h.a.NoError(err) + h.a.Equal("foo", u.Username) + h.a.Equal("baz", u.Handle) +} + +func TestScimUserReplace(t *testing.T) { + h := newHelper(t) + h.clearUsers() + + u := h.createUserWithEmail(h.randEmail()) + + h.scimApiInit(). + Debug(). + Put(fmt.Sprintf("/Users/%d", u.ID)). + JSON(`{ + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:User" + ], + "userName": "bar", + "emails": [ + { + "value": "foo@bar.com" + } + ] +}`). + Expect(t). + //Status(http.StatusNoContent). + End() + + u, err := store.LookupUserByID(context.Background(), service.DefaultStore, u.ID) + h.a.NoError(err) + h.a.NotNil(u) + h.a.Equal("foo@bar.com", u.Email) +} + +func TestScimUserDelete(t *testing.T) { + h := newHelper(t) + h.clearUsers() + + u := h.createUserWithEmail(h.randEmail()) + + h.scimApiInit(). + Delete(fmt.Sprintf("/Users/%d", u.ID)). + Expect(t). + Status(http.StatusNoContent). + End() +} From 58fb2157eb9f3a6124dfb7bd0580f6e649a0dbf6 Mon Sep 17 00:00:00 2001 From: Denis Arh Date: Sun, 29 Nov 2020 09:37:01 +0100 Subject: [PATCH 02/12] Improve overall SCIM req. handling --- system/scim/gen_response.go | 39 +++++++++++++++++++++++++++++++++++++ system/scim/http.go | 7 ++++++- system/scim/user_handler.go | 19 ++++++++---------- 3 files changed, 53 insertions(+), 12 deletions(-) diff --git a/system/scim/gen_response.go b/system/scim/gen_response.go index 5c985ccaf..33e32dc2b 100644 --- a/system/scim/gen_response.go +++ b/system/scim/gen_response.go @@ -2,6 +2,7 @@ package scim import ( "github.com/cortezaproject/corteza-server/system/types" + "net/http" "time" ) @@ -11,6 +12,17 @@ type ( Created time.Time `json:"created"` LastModified *time.Time `json:"lastModified,omitempty"` } + + errorResponse struct { + Schemas []string `json:"schemas"` + SCIMType string `json:"scimType,omitempty"` + Detail string `json:"detail,omitempty"` + Status int `json:"status,string"` + } +) + +const ( + urnError = "urn:ietf:params:scim:api:messages:2.0:Error" ) func newUserMetaResponse(u *types.User) *metaResponse { @@ -22,3 +34,30 @@ func newUserMetaResponse(u *types.User) *metaResponse { return rsp } + +func newGroupMetaResponse(u *types.Role) *metaResponse { + rsp := &metaResponse{ + ResourceType: "Group", + Created: u.CreatedAt, + LastModified: u.UpdatedAt, + } + + return rsp +} + +func newErrorResonse(httpStatus int, err error) *errorResponse { + if httpStatus == 0 { + httpStatus = http.StatusInternalServerError + } + + er := &errorResponse{ + Schemas: []string{urnError}, + Status: httpStatus, + } + + if err != nil { + er.Detail = err.Error() + } + + return er +} diff --git a/system/scim/http.go b/system/scim/http.go index 5e7d908e2..d48a8b74f 100644 --- a/system/scim/http.go +++ b/system/scim/http.go @@ -6,9 +6,14 @@ import ( "net/http" ) -func send(w http.ResponseWriter, payload interface{}) { +func send(w http.ResponseWriter, status int, payload interface{}) { w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) if err := json.NewEncoder(w).Encode(payload); err != nil { log.Error("could not encode payload", zap.Error(err)) } } + +func sendError(w http.ResponseWriter, err *errorResponse) { + send(w, err.Status, err) +} diff --git a/system/scim/user_handler.go b/system/scim/user_handler.go index 057793da1..42049e7bd 100644 --- a/system/scim/user_handler.go +++ b/system/scim/user_handler.go @@ -3,7 +3,6 @@ package scim import ( "context" "fmt" - "github.com/cortezaproject/corteza-server/pkg/api" "github.com/cortezaproject/corteza-server/pkg/auth" "github.com/cortezaproject/corteza-server/pkg/errors" "github.com/cortezaproject/corteza-server/system/service" @@ -33,13 +32,13 @@ func (h usersHandler) get(w http.ResponseWriter, r *http.Request) { } if u, err := svc.FindByID(id); err != nil { - errors.ServeHTTP(w, r, err, !api.DebugFromContext(r.Context())) + sendError(w, newErrorResonse(http.StatusBadRequest, err)) return } else { - send(w, newUserResourceResponse(u)) + send(w, http.StatusOK, newUserResourceResponse(u)) } - w.WriteHeader(200) + w.WriteHeader(http.StatusOK) } func (h usersHandler) create(w http.ResponseWriter, r *http.Request) { @@ -50,10 +49,9 @@ func (h usersHandler) create(w http.ResponseWriter, r *http.Request) { ) if u, err := h.createFromJSON(ctx, r.Body); err != nil { - errors.ServeHTTP(w, r, err, !api.DebugFromContext(r.Context())) + sendError(w, newErrorResonse(http.StatusBadRequest, err)) } else { - w.WriteHeader(http.StatusCreated) - send(w, newUserResourceResponse(u)) + send(w, http.StatusCreated, newUserResourceResponse(u)) } } @@ -99,10 +97,9 @@ func (h usersHandler) replace(w http.ResponseWriter, r *http.Request) { ) if u, err := h.updateFromJSON(ctx, userID, r.Body); err != nil { - errors.ServeHTTP(w, r, err, !api.DebugFromContext(r.Context())) + sendError(w, newErrorResonse(http.StatusBadRequest, err)) } else { - w.WriteHeader(http.StatusOK) - send(w, newUserResourceResponse(u)) + send(w, http.StatusOK, newUserResourceResponse(u)) } } @@ -137,7 +134,7 @@ func (h usersHandler) delete(w http.ResponseWriter, r *http.Request) { ) if err := svc.Delete(userID); err != nil { - send(w, err) + sendError(w, newErrorResonse(http.StatusBadRequest, err)) } else { w.WriteHeader(http.StatusNoContent) } From 41eae955ca0599338dfb190f74599516848363f5 Mon Sep 17 00:00:00 2001 From: Denis Arh Date: Sun, 29 Nov 2020 09:37:22 +0100 Subject: [PATCH 03/12] Add support to provision roles (groups) via SCIM --- system/scim/group_handler.go | 138 ++++++++++++++++++++++++++++++++++ system/scim/group_payloads.go | 61 +++++++++++++++ system/scim/routes.go | 9 ++- tests/system/scim_test.go | 76 +++++++++++++++++++ 4 files changed, 283 insertions(+), 1 deletion(-) create mode 100644 system/scim/group_handler.go create mode 100644 system/scim/group_payloads.go diff --git a/system/scim/group_handler.go b/system/scim/group_handler.go new file mode 100644 index 000000000..99d08f724 --- /dev/null +++ b/system/scim/group_handler.go @@ -0,0 +1,138 @@ +package scim + +import ( + "context" + "fmt" + "github.com/cortezaproject/corteza-server/pkg/auth" + "github.com/cortezaproject/corteza-server/pkg/errors" + "github.com/cortezaproject/corteza-server/system/service" + "github.com/cortezaproject/corteza-server/system/types" + "github.com/go-chi/chi" + "io" + "net/http" + "strconv" +) + +type ( + groupsHandler struct { + svc service.RoleService + } +) + +func (h groupsHandler) get(w http.ResponseWriter, r *http.Request) { + var ( + id, _ = strconv.ParseUint(chi.URLParam(r, "id"), 10, 64) + ctx = auth.SetSuperUserContext(r.Context()) + svc = h.svc.With(ctx) + ) + + if id == 0 { + http.Error(w, "invalid group id", http.StatusBadRequest) + return + } + + if u, err := svc.FindByID(id); err != nil { + sendError(w, newErrorResonse(http.StatusBadRequest, err)) + } else { + send(w, http.StatusOK, newGroupResourceResponse(u)) + } +} + +func (h groupsHandler) create(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + + var ( + ctx = auth.SetSuperUserContext(r.Context()) + ) + + if u, err := h.createFromJSON(ctx, r.Body); err != nil { + sendError(w, newErrorResonse(http.StatusBadRequest, err)) + } else { + send(w, http.StatusCreated, newGroupResourceResponse(u)) + } +} + +func (h groupsHandler) createFromJSON(ctx context.Context, j io.Reader) (r *types.Role, err error) { + var ( + svc = h.svc.With(ctx) + payload = &groupResourceRequest{} + ) + + if err = payload.decodeJSON(j); err != nil { + return + } + + // do we need to upsert? + if *payload.Name != "" { + r, err = svc.FindByName(*payload.Name) + if err != nil && !errors.Is(err, service.RoleErrNotFound()) { + return + } + } + + if r == nil || r.ID == 0 { + // in case when we did not find a valid group, + // start from blank + r = &types.Role{} + } + + payload.applyTo(r) + + if r.ID > 0 { + return svc.Update(r) + } else { + return svc.Create(r) + } +} + +func (h groupsHandler) replace(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + + var ( + ctx = auth.SetSuperUserContext(r.Context()) + groupID, _ = strconv.ParseUint(chi.URLParam(r, "id"), 10, 64) + ) + + if u, err := h.updateFromJSON(ctx, groupID, r.Body); err != nil { + sendError(w, newErrorResonse(http.StatusBadRequest, err)) + } else { + send(w, http.StatusOK, newGroupResourceResponse(u)) + } +} + +func (h groupsHandler) updateFromJSON(ctx context.Context, id uint64, j io.Reader) (r *types.Role, err error) { + var ( + svc = h.svc.With(ctx) + payload = &groupResourceRequest{} + ) + + if r, err = svc.FindByID(id); err != nil { + return + } + + if r == nil { + return nil, fmt.Errorf("refusing to update invalid group") + } + + if err = payload.decodeJSON(j); err != nil { + return + } + + payload.applyTo(r) + + return h.svc.With(ctx).Update(r) +} + +func (h groupsHandler) delete(w http.ResponseWriter, r *http.Request) { + var ( + ctx = auth.SetSuperUserContext(r.Context()) + groupID, _ = strconv.ParseUint(chi.URLParam(r, "id"), 10, 64) + svc = h.svc.With(ctx) + ) + + if err := svc.Delete(groupID); err != nil { + sendError(w, newErrorResonse(http.StatusBadRequest, err)) + } else { + w.WriteHeader(http.StatusNoContent) + } +} diff --git a/system/scim/group_payloads.go b/system/scim/group_payloads.go new file mode 100644 index 000000000..77929a491 --- /dev/null +++ b/system/scim/group_payloads.go @@ -0,0 +1,61 @@ +package scim + +import ( + "encoding/json" + "fmt" + "github.com/cortezaproject/corteza-server/system/types" + "io" + "strconv" +) + +const ( + urnGroup = "urn:ietf:params:scim:schemas:core:2.0:Group" + groupLabel_SCIM_externalId = "SCIM_externalId" +) + +type ( + groupResourceResponse struct { + Schemas []string `json:"schemas"` + Meta *metaResponse `json:"meta,omitempty"` + ID string `json:"id,omitempty"` + ExternalId string `json:"externalId,omitempty"` + Name string `json:"displayName"` + } + + groupResourceRequest struct { + Schemas []string `json:"schemas"` + Meta *metaResponse `json:"meta,omitempty"` + ExternalId *string `json:"externalId,omitempty"` + Name *string `json:"displayName"` + } +) + +func newGroupResourceResponse(u *types.Role) *groupResourceResponse { + rsp := &groupResourceResponse{ + Schemas: []string{urnGroup}, + Meta: newGroupMetaResponse(u), + ID: strconv.FormatUint(u.ID, 10), + ExternalId: u.Labels[groupLabel_SCIM_externalId], + Name: u.Name, + } + + return rsp +} + +func (req *groupResourceRequest) decodeJSON(r io.Reader) error { + if err := json.NewDecoder(r).Decode(req); err != nil { + return fmt.Errorf("could not decode group payload: %w", err) + } + + return nil +} + +func (req *groupResourceRequest) applyTo(u *types.Role) { + if req.Name != nil { + u.Name = *req.Name + } + + if req.ExternalId != nil { + u.SetLabel("SCIM_externalId", *req.ExternalId) + } +} diff --git a/system/scim/routes.go b/system/scim/routes.go index fa9093827..610c1e3f8 100644 --- a/system/scim/routes.go +++ b/system/scim/routes.go @@ -44,11 +44,18 @@ func Guard(opt options.SCIMOpt) func(next http.Handler) http.Handler { func Routes(r chi.Router) { uh := &usersHandler{svc: service.DefaultUser} - r.Route("/Users", func(r chi.Router) { r.Get("/{id}", uh.get) r.Post("/", uh.create) r.Put("/{id}", uh.replace) r.Delete("/{id}", uh.delete) }) + + gh := &groupsHandler{svc: service.DefaultRole} + r.Route("/Groups", func(r chi.Router) { + r.Get("/{id}", gh.get) + r.Post("/", gh.create) + r.Put("/{id}", gh.replace) + r.Delete("/{id}", gh.delete) + }) } diff --git a/tests/system/scim_test.go b/tests/system/scim_test.go index dce3db37c..b6ae6d0fb 100644 --- a/tests/system/scim_test.go +++ b/tests/system/scim_test.go @@ -127,3 +127,79 @@ func TestScimUserDelete(t *testing.T) { Status(http.StatusNoContent). End() } + +func TestScimGroupGet(t *testing.T) { + h := newHelper(t) + h.clearRoles() + + u := h.repoMakeRole() + + h.scimApiInit(). + Get(fmt.Sprintf("/Groups/%d", u.ID)). + Expect(t). + Status(http.StatusOK). + Assert(helpers.AssertNoErrors). + Assert(jsonpath.Contains(`$.schemas`, "urn:ietf:params:scim:schemas:core:2.0:Group")). + Assert(jsonpath.Equal(`$.id`, fmt.Sprintf("%d", u.ID))). + End() +} + +func TestScimGroupCreate(t *testing.T) { + h := newHelper(t) + h.clearRoles() + + h.scimApiInit(). + Debug(). + Post("/Groups"). + JSON(`{ + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:Group" + ], + "displayName": "foo" +}`). + Expect(t). + Status(http.StatusCreated). + Assert(helpers.AssertNoErrors). + End() + + u, err := store.LookupRoleByName(context.Background(), service.DefaultStore, "foo") + h.a.NoError(err) + h.a.Equal("foo", u.Name) +} + +func TestScimGroupReplace(t *testing.T) { + h := newHelper(t) + h.clearRoles() + + u := h.repoMakeRole() + + h.scimApiInit(). + Debug(). + Put(fmt.Sprintf("/Groups/%d", u.ID)). + JSON(`{ + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:Group" + ], + "displayName": "bar" +}`). + Expect(t). + End() + + u, err := store.LookupRoleByID(context.Background(), service.DefaultStore, u.ID) + h.a.NoError(err) + h.a.NotNil(u) + h.a.Equal("bar", u.Name) +} + +func TestScimGroupDelete(t *testing.T) { + h := newHelper(t) + h.clearRoles() + + u := h.repoMakeRole(h.randEmail()) + + h.scimApiInit(). + Delete(fmt.Sprintf("/Groups/%d", u.ID)). + Expect(t). + Status(http.StatusNoContent). + End() +} From 99f0ca32490b4e453c3d2c0010fa476c213b88a3 Mon Sep 17 00:00:00 2001 From: Denis Arh Date: Sun, 29 Nov 2020 09:57:46 +0100 Subject: [PATCH 04/12] Add support for external ID --- system/scim/group_handler.go | 12 ++++- system/scim/user_handler.go | 12 ++++- tests/system/scim_test.go | 99 +++++++++++++++++++++++++++++++----- 3 files changed, 108 insertions(+), 15 deletions(-) diff --git a/system/scim/group_handler.go b/system/scim/group_handler.go index 99d08f724..298470ff0 100644 --- a/system/scim/group_handler.go +++ b/system/scim/group_handler.go @@ -63,7 +63,17 @@ func (h groupsHandler) createFromJSON(ctx context.Context, j io.Reader) (r *type } // do we need to upsert? - if *payload.Name != "" { + if payload.ExternalId != nil { + var rr types.RoleSet + rr, _, err = svc.Find(types.RoleFilter{Labels: map[string]string{groupLabel_SCIM_externalId: *payload.ExternalId}}) + if err != nil { + return + } + + if len(rr) > 0 { + r = rr[0] + } + } else if payload.Name != nil { r, err = svc.FindByName(*payload.Name) if err != nil && !errors.Is(err, service.RoleErrNotFound()) { return diff --git a/system/scim/user_handler.go b/system/scim/user_handler.go index 42049e7bd..3eddf9abe 100644 --- a/system/scim/user_handler.go +++ b/system/scim/user_handler.go @@ -66,7 +66,17 @@ func (h usersHandler) createFromJSON(ctx context.Context, j io.Reader) (u *types } // do we need to upsert? - if email := payload.Emails.getFirst(); email != "" { + if payload.ExternalId != nil { + var uu types.UserSet + uu, _, err = svc.Find(types.UserFilter{Labels: map[string]string{userLabel_SCIM_externalId: *payload.ExternalId}}) + if err != nil { + return + } + + if len(uu) > 0 { + u = uu[0] + } + } else if email := payload.Emails.getFirst(); email != "" { u, err = svc.FindByEmail(email) if err != nil && !errors.Is(err, service.UserErrNotFound()) { return diff --git a/tests/system/scim_test.go b/tests/system/scim_test.go index b6ae6d0fb..48318bab7 100644 --- a/tests/system/scim_test.go +++ b/tests/system/scim_test.go @@ -85,6 +85,58 @@ func TestScimUserCreate(t *testing.T) { h.a.Equal("baz", u.Handle) } +func TestScimUserCreateOverwrite(t *testing.T) { + h := newHelper(t) + h.clearUsers() + + u := h.createUserWithEmail("foo@bar.com") + + h.scimApiInit(). + Debug(). + Post("/Users"). + JSON(`{"userName":"UPDATED","emails":[{"value":"foo@bar.com"}],"schemas":["urn:ietf:params:scim:schemas:core:2.0:User"]}`). + Expect(t). + Status(http.StatusCreated). + Assert(helpers.AssertNoErrors). + End() + + u, err := store.LookupUserByEmail(context.Background(), service.DefaultStore, "foo@bar.com") + h.a.NoError(err) + h.a.Equal("UPDATED", u.Username) +} + +func TestScimUserExternalID(t *testing.T) { + h := newHelper(t) + h.clearUsers() + + h.scimApiInit(). + Debug(). + Post("/Users"). + JSON(`{"userName":"foo","emails":[{"value":"foo@bar.com"}],"externalId":"foo42","schemas":["urn:ietf:params:scim:schemas:core:2.0:User"]}`). + Expect(t). + Status(http.StatusCreated). + Assert(helpers.AssertNoErrors). + End() + + u, err := store.LookupUserByEmail(context.Background(), service.DefaultStore, "foo@bar.com") + h.a.NoError(err) + h.a.Equal("foo", u.Username) + + h.scimApiInit(). + Debug(). + Post("/Users"). + JSON(`{"userName":"baz","emails":[{"value":"baz@bar.com"}],"externalId":"foo42","schemas":["urn:ietf:params:scim:schemas:core:2.0:User"]}`). + Expect(t). + Status(http.StatusCreated). + Assert(helpers.AssertNoErrors). + End() + + u, err = store.LookupUserByEmail(context.Background(), service.DefaultStore, "baz@bar.com") + h.a.NoError(err) + h.a.Equal("baz", u.Username) + +} + func TestScimUserReplace(t *testing.T) { h := newHelper(t) h.clearUsers() @@ -106,7 +158,7 @@ func TestScimUserReplace(t *testing.T) { ] }`). Expect(t). - //Status(http.StatusNoContent). + Status(http.StatusOK). End() u, err := store.LookupUserByID(context.Background(), service.DefaultStore, u.ID) @@ -151,12 +203,7 @@ func TestScimGroupCreate(t *testing.T) { h.scimApiInit(). Debug(). Post("/Groups"). - JSON(`{ - "schemas": [ - "urn:ietf:params:scim:schemas:core:2.0:Group" - ], - "displayName": "foo" -}`). + JSON(`{"schemas":["urn:ietf:params:scim:schemas:core:2.0:Group"],"displayName":"foo"}`). Expect(t). Status(http.StatusCreated). Assert(helpers.AssertNoErrors). @@ -167,6 +214,37 @@ func TestScimGroupCreate(t *testing.T) { h.a.Equal("foo", u.Name) } +func TestScimGroupExternalId(t *testing.T) { + h := newHelper(t) + h.clearRoles() + + h.scimApiInit(). + Debug(). + Post("/Groups"). + JSON(`{"schemas":["urn:ietf:params:scim:schemas:core:2.0:Group"],"displayName":"foo","externalId":"grp42"}`). + Expect(t). + Status(http.StatusCreated). + Assert(helpers.AssertNoErrors). + End() + + u, err := store.LookupRoleByName(context.Background(), service.DefaultStore, "foo") + h.a.NoError(err) + h.a.Equal("foo", u.Name) + + h.scimApiInit(). + Debug(). + Post("/Groups"). + JSON(`{"schemas":["urn:ietf:params:scim:schemas:core:2.0:Group"],"displayName":"bar","externalId":"grp42"}`). + Expect(t). + Status(http.StatusCreated). + Assert(helpers.AssertNoErrors). + End() + + u, err = store.LookupRoleByName(context.Background(), service.DefaultStore, "bar") + h.a.NoError(err) + h.a.Equal("bar", u.Name) +} + func TestScimGroupReplace(t *testing.T) { h := newHelper(t) h.clearRoles() @@ -176,12 +254,7 @@ func TestScimGroupReplace(t *testing.T) { h.scimApiInit(). Debug(). Put(fmt.Sprintf("/Groups/%d", u.ID)). - JSON(`{ - "schemas": [ - "urn:ietf:params:scim:schemas:core:2.0:Group" - ], - "displayName": "bar" -}`). + JSON(`{"schemas":["urn:ietf:params:scim:schemas:core:2.0:Group"],"displayName":"bar"}`). Expect(t). End() From 4899a1bfec9c0a89271d2b8acd6bcd5303a379be Mon Sep 17 00:00:00 2001 From: Denis Arh Date: Sun, 29 Nov 2020 09:58:30 +0100 Subject: [PATCH 05/12] Remove API request debug() flag from SCIM tests --- tests/system/scim_test.go | 50 ++++++++++++++++++++++++--------------- 1 file changed, 31 insertions(+), 19 deletions(-) diff --git a/tests/system/scim_test.go b/tests/system/scim_test.go index 48318bab7..a5154a893 100644 --- a/tests/system/scim_test.go +++ b/tests/system/scim_test.go @@ -8,7 +8,6 @@ import ( "github.com/cortezaproject/corteza-server/store" "github.com/cortezaproject/corteza-server/system/scim" "github.com/cortezaproject/corteza-server/system/service" - "github.com/cortezaproject/corteza-server/tests/helpers" "github.com/go-chi/chi" "github.com/steinfletcher/apitest" jsonpath "github.com/steinfletcher/apitest-jsonpath" @@ -45,7 +44,6 @@ func TestScimUserGet(t *testing.T) { Get(fmt.Sprintf("/Users/%d", u.ID)). Expect(t). Status(http.StatusOK). - Assert(helpers.AssertNoErrors). Assert(jsonpath.Contains(`$.schemas`, "urn:ietf:params:scim:schemas:core:2.0:User")). Assert(jsonpath.Equal(`$.id`, fmt.Sprintf("%d", u.ID))). End() @@ -56,7 +54,6 @@ func TestScimUserCreate(t *testing.T) { h.clearUsers() h.scimApiInit(). - Debug(). Post("/Users"). JSON(`{ "schemas": [ @@ -76,7 +73,6 @@ func TestScimUserCreate(t *testing.T) { }`). Expect(t). Status(http.StatusCreated). - Assert(helpers.AssertNoErrors). End() u, err := store.LookupUserByEmail(context.Background(), service.DefaultStore, "foo@bar.com") @@ -85,6 +81,18 @@ func TestScimUserCreate(t *testing.T) { h.a.Equal("baz", u.Handle) } +func TestScimUserCreateNoEmail(t *testing.T) { + h := newHelper(t) + h.clearUsers() + + h.scimApiInit(). + Post("/Users"). + JSON(`{"schemas":["urn:ietf:params:scim:schemas:core:2.0:User"]}`). + Expect(t). + Status(http.StatusBadRequest). + End() +} + func TestScimUserCreateOverwrite(t *testing.T) { h := newHelper(t) h.clearUsers() @@ -92,12 +100,10 @@ func TestScimUserCreateOverwrite(t *testing.T) { u := h.createUserWithEmail("foo@bar.com") h.scimApiInit(). - Debug(). Post("/Users"). JSON(`{"userName":"UPDATED","emails":[{"value":"foo@bar.com"}],"schemas":["urn:ietf:params:scim:schemas:core:2.0:User"]}`). Expect(t). Status(http.StatusCreated). - Assert(helpers.AssertNoErrors). End() u, err := store.LookupUserByEmail(context.Background(), service.DefaultStore, "foo@bar.com") @@ -110,12 +116,10 @@ func TestScimUserExternalID(t *testing.T) { h.clearUsers() h.scimApiInit(). - Debug(). Post("/Users"). JSON(`{"userName":"foo","emails":[{"value":"foo@bar.com"}],"externalId":"foo42","schemas":["urn:ietf:params:scim:schemas:core:2.0:User"]}`). Expect(t). Status(http.StatusCreated). - Assert(helpers.AssertNoErrors). End() u, err := store.LookupUserByEmail(context.Background(), service.DefaultStore, "foo@bar.com") @@ -123,12 +127,10 @@ func TestScimUserExternalID(t *testing.T) { h.a.Equal("foo", u.Username) h.scimApiInit(). - Debug(). Post("/Users"). JSON(`{"userName":"baz","emails":[{"value":"baz@bar.com"}],"externalId":"foo42","schemas":["urn:ietf:params:scim:schemas:core:2.0:User"]}`). Expect(t). Status(http.StatusCreated). - Assert(helpers.AssertNoErrors). End() u, err = store.LookupUserByEmail(context.Background(), service.DefaultStore, "baz@bar.com") @@ -144,7 +146,6 @@ func TestScimUserReplace(t *testing.T) { u := h.createUserWithEmail(h.randEmail()) h.scimApiInit(). - Debug(). Put(fmt.Sprintf("/Users/%d", u.ID)). JSON(`{ "schemas": [ @@ -167,6 +168,25 @@ func TestScimUserReplace(t *testing.T) { h.a.Equal("foo@bar.com", u.Email) } +func TestScimUserPassword(t *testing.T) { + h := newHelper(t) + h.clearUsers() + + service.CurrentSettings.Auth.Internal.Enabled = true + auth := service.Auth() + + h.scimApiInit(). + Post("/Users"). + JSON(`{"password":"foo$bar$baz 42","emails":[{"value":"baz@bar.com"}],"externalId":"foo42","schemas":["urn:ietf:params:scim:schemas:core:2.0:User"]}`). + Expect(t). + Status(http.StatusCreated). + End() + + u, err := auth.InternalLogin(context.Background(), "baz@bar.com", "foo$bar$baz 42") + h.a.NoError(err) + h.a.NotNil(u) +} + func TestScimUserDelete(t *testing.T) { h := newHelper(t) h.clearUsers() @@ -190,7 +210,6 @@ func TestScimGroupGet(t *testing.T) { Get(fmt.Sprintf("/Groups/%d", u.ID)). Expect(t). Status(http.StatusOK). - Assert(helpers.AssertNoErrors). Assert(jsonpath.Contains(`$.schemas`, "urn:ietf:params:scim:schemas:core:2.0:Group")). Assert(jsonpath.Equal(`$.id`, fmt.Sprintf("%d", u.ID))). End() @@ -201,12 +220,10 @@ func TestScimGroupCreate(t *testing.T) { h.clearRoles() h.scimApiInit(). - Debug(). Post("/Groups"). JSON(`{"schemas":["urn:ietf:params:scim:schemas:core:2.0:Group"],"displayName":"foo"}`). Expect(t). Status(http.StatusCreated). - Assert(helpers.AssertNoErrors). End() u, err := store.LookupRoleByName(context.Background(), service.DefaultStore, "foo") @@ -219,12 +236,10 @@ func TestScimGroupExternalId(t *testing.T) { h.clearRoles() h.scimApiInit(). - Debug(). Post("/Groups"). JSON(`{"schemas":["urn:ietf:params:scim:schemas:core:2.0:Group"],"displayName":"foo","externalId":"grp42"}`). Expect(t). Status(http.StatusCreated). - Assert(helpers.AssertNoErrors). End() u, err := store.LookupRoleByName(context.Background(), service.DefaultStore, "foo") @@ -232,12 +247,10 @@ func TestScimGroupExternalId(t *testing.T) { h.a.Equal("foo", u.Name) h.scimApiInit(). - Debug(). Post("/Groups"). JSON(`{"schemas":["urn:ietf:params:scim:schemas:core:2.0:Group"],"displayName":"bar","externalId":"grp42"}`). Expect(t). Status(http.StatusCreated). - Assert(helpers.AssertNoErrors). End() u, err = store.LookupRoleByName(context.Background(), service.DefaultStore, "bar") @@ -252,7 +265,6 @@ func TestScimGroupReplace(t *testing.T) { u := h.repoMakeRole() h.scimApiInit(). - Debug(). Put(fmt.Sprintf("/Groups/%d", u.ID)). JSON(`{"schemas":["urn:ietf:params:scim:schemas:core:2.0:Group"],"displayName":"bar"}`). Expect(t). From 3589f54349c6855e839643c21c7d637a50514869 Mon Sep 17 00:00:00 2001 From: Denis Arh Date: Sun, 29 Nov 2020 10:04:16 +0100 Subject: [PATCH 06/12] Refactor SCIM security context --- system/scim/group_handler.go | 10 +++++----- system/scim/routes.go | 13 +++++++++++-- system/scim/security.go | 17 +++++++++++++++++ system/scim/user_handler.go | 10 +++++----- 4 files changed, 38 insertions(+), 12 deletions(-) create mode 100644 system/scim/security.go diff --git a/system/scim/group_handler.go b/system/scim/group_handler.go index 298470ff0..0f2ddf792 100644 --- a/system/scim/group_handler.go +++ b/system/scim/group_handler.go @@ -3,7 +3,6 @@ package scim import ( "context" "fmt" - "github.com/cortezaproject/corteza-server/pkg/auth" "github.com/cortezaproject/corteza-server/pkg/errors" "github.com/cortezaproject/corteza-server/system/service" "github.com/cortezaproject/corteza-server/system/types" @@ -16,13 +15,14 @@ import ( type ( groupsHandler struct { svc service.RoleService + sec getSecurityContextFn } ) func (h groupsHandler) get(w http.ResponseWriter, r *http.Request) { var ( id, _ = strconv.ParseUint(chi.URLParam(r, "id"), 10, 64) - ctx = auth.SetSuperUserContext(r.Context()) + ctx = h.sec(r) svc = h.svc.With(ctx) ) @@ -42,7 +42,7 @@ func (h groupsHandler) create(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() var ( - ctx = auth.SetSuperUserContext(r.Context()) + ctx = h.sec(r) ) if u, err := h.createFromJSON(ctx, r.Body); err != nil { @@ -99,7 +99,7 @@ func (h groupsHandler) replace(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() var ( - ctx = auth.SetSuperUserContext(r.Context()) + ctx = h.sec(r) groupID, _ = strconv.ParseUint(chi.URLParam(r, "id"), 10, 64) ) @@ -135,7 +135,7 @@ func (h groupsHandler) updateFromJSON(ctx context.Context, id uint64, j io.Reade func (h groupsHandler) delete(w http.ResponseWriter, r *http.Request) { var ( - ctx = auth.SetSuperUserContext(r.Context()) + ctx = h.sec(r) groupID, _ = strconv.ParseUint(chi.URLParam(r, "id"), 10, 64) svc = h.svc.With(ctx) ) diff --git a/system/scim/routes.go b/system/scim/routes.go index 610c1e3f8..2f06da0a7 100644 --- a/system/scim/routes.go +++ b/system/scim/routes.go @@ -43,16 +43,25 @@ func Guard(opt options.SCIMOpt) func(next http.Handler) http.Handler { } func Routes(r chi.Router) { - uh := &usersHandler{svc: service.DefaultUser} + r.Route("/Users", func(r chi.Router) { + uh := &usersHandler{ + svc: service.DefaultUser, + sec: getSecurityContext, + } + r.Get("/{id}", uh.get) r.Post("/", uh.create) r.Put("/{id}", uh.replace) r.Delete("/{id}", uh.delete) }) - gh := &groupsHandler{svc: service.DefaultRole} r.Route("/Groups", func(r chi.Router) { + gh := &groupsHandler{ + svc: service.DefaultRole, + sec: getSecurityContext, + } + r.Get("/{id}", gh.get) r.Post("/", gh.create) r.Put("/{id}", gh.replace) diff --git a/system/scim/security.go b/system/scim/security.go new file mode 100644 index 000000000..f77bf0471 --- /dev/null +++ b/system/scim/security.go @@ -0,0 +1,17 @@ +package scim + +import ( + "context" + "github.com/cortezaproject/corteza-server/pkg/auth" + "net/http" +) + +type ( + getSecurityContextFn func(r *http.Request) context.Context +) + +// All actions are in security context of a superuser for now +// +func getSecurityContext(r *http.Request) context.Context { + return auth.SetSuperUserContext(r.Context()) +} diff --git a/system/scim/user_handler.go b/system/scim/user_handler.go index 3eddf9abe..b8877a002 100644 --- a/system/scim/user_handler.go +++ b/system/scim/user_handler.go @@ -3,7 +3,6 @@ package scim import ( "context" "fmt" - "github.com/cortezaproject/corteza-server/pkg/auth" "github.com/cortezaproject/corteza-server/pkg/errors" "github.com/cortezaproject/corteza-server/system/service" "github.com/cortezaproject/corteza-server/system/types" @@ -16,13 +15,14 @@ import ( type ( usersHandler struct { svc service.UserService + sec getSecurityContextFn } ) func (h usersHandler) get(w http.ResponseWriter, r *http.Request) { var ( id, _ = strconv.ParseUint(chi.URLParam(r, "id"), 10, 64) - ctx = auth.SetSuperUserContext(r.Context()) + ctx = h.sec(r) svc = h.svc.With(ctx) ) @@ -45,7 +45,7 @@ func (h usersHandler) create(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() var ( - ctx = auth.SetSuperUserContext(r.Context()) + ctx = h.sec(r) ) if u, err := h.createFromJSON(ctx, r.Body); err != nil { @@ -102,7 +102,7 @@ func (h usersHandler) replace(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() var ( - ctx = auth.SetSuperUserContext(r.Context()) + ctx = h.sec(r) userID, _ = strconv.ParseUint(chi.URLParam(r, "id"), 10, 64) ) @@ -138,7 +138,7 @@ func (h usersHandler) updateFromJSON(ctx context.Context, id uint64, j io.Reader func (h usersHandler) delete(w http.ResponseWriter, r *http.Request) { var ( - ctx = auth.SetSuperUserContext(r.Context()) + ctx = h.sec(r) userID, _ = strconv.ParseUint(chi.URLParam(r, "id"), 10, 64) svc = h.svc.With(ctx) ) From d36ccf4522bd328cbf2c50cc2d5b7008609c504b Mon Sep 17 00:00:00 2001 From: Denis Arh Date: Sun, 29 Nov 2020 10:14:44 +0100 Subject: [PATCH 07/12] Add membership management basics --- system/scim/user_handler.go | 23 ++++++++++++++++++----- system/scim/user_payloads.go | 6 ++++++ 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/system/scim/user_handler.go b/system/scim/user_handler.go index b8877a002..17c0cb085 100644 --- a/system/scim/user_handler.go +++ b/system/scim/user_handler.go @@ -14,8 +14,9 @@ import ( type ( usersHandler struct { - svc service.UserService - sec getSecurityContextFn + svc service.UserService + rleSvc service.RoleService + sec getSecurityContextFn } ) @@ -57,7 +58,8 @@ func (h usersHandler) create(w http.ResponseWriter, r *http.Request) { func (h usersHandler) createFromJSON(ctx context.Context, j io.Reader) (u *types.User, err error) { var ( - svc = h.svc.With(ctx) + svc = h.svc.With(ctx) + //roles = h.rleSvc.With(ctx) payload = &userResourceRequest{} ) @@ -92,10 +94,21 @@ func (h usersHandler) createFromJSON(ctx context.Context, j io.Reader) (u *types payload.applyTo(u) if u.ID > 0 { - return svc.Update(u) + u, err = svc.Update(u) } else { - return svc.Create(u) + u, err = svc.Create(u) } + + if err != nil { + return + } + + if payload.Groups != nil { + // remove existing, add new + // @todo + } + + return u, nil } func (h usersHandler) replace(w http.ResponseWriter, r *http.Request) { diff --git a/system/scim/user_payloads.go b/system/scim/user_payloads.go index 2b94f2f91..6f64901e8 100644 --- a/system/scim/user_payloads.go +++ b/system/scim/user_payloads.go @@ -26,6 +26,10 @@ type ( Formatted string `json:"formatted"` } + userGroupMembershipRequest struct { + Value string `json:"value"` + } + userResourceResponse struct { Schemas []string `json:"schemas"` Meta *metaResponse `json:"meta,omitempty"` @@ -46,6 +50,8 @@ type ( Password *string `json:"password,omitempty"` Name *userNameResponse `json:"name"` Emails emailsResponse `json:"emails,omitempty"` + + Groups []*userGroupMembershipRequest `json:"groups,omitempty"` } ) From 0ef0682d3da5e0607af7a4761d9413ee8904df42 Mon Sep 17 00:00:00 2001 From: Denis Arh Date: Sun, 29 Nov 2020 12:23:07 +0100 Subject: [PATCH 08/12] Add support for setting user password via SCIM --- system/scim/routes.go | 6 +++--- system/scim/user_handler.go | 25 ++++++++++++++++++++++--- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/system/scim/routes.go b/system/scim/routes.go index 2f06da0a7..074054b2b 100644 --- a/system/scim/routes.go +++ b/system/scim/routes.go @@ -43,11 +43,11 @@ func Guard(opt options.SCIMOpt) func(next http.Handler) http.Handler { } func Routes(r chi.Router) { - r.Route("/Users", func(r chi.Router) { uh := &usersHandler{ - svc: service.DefaultUser, - sec: getSecurityContext, + svc: service.DefaultUser, + passSvc: service.DefaultAuth, + sec: getSecurityContext, } r.Get("/{id}", uh.get) diff --git a/system/scim/user_handler.go b/system/scim/user_handler.go index 17c0cb085..bbb00fab1 100644 --- a/system/scim/user_handler.go +++ b/system/scim/user_handler.go @@ -13,10 +13,15 @@ import ( ) type ( + passwordSetter interface { + SetPassword(context.Context, uint64, string) error + } + usersHandler struct { - svc service.UserService - rleSvc service.RoleService - sec getSecurityContextFn + svc service.UserService + rleSvc service.RoleService + passSvc passwordSetter + sec getSecurityContextFn } ) @@ -108,6 +113,13 @@ func (h usersHandler) createFromJSON(ctx context.Context, j io.Reader) (u *types // @todo } + if payload.Password != nil && *payload.Password != "" { + err = h.passSvc.SetPassword(ctx, u.ID, *payload.Password) + if err != nil { + return + } + } + return u, nil } @@ -146,6 +158,13 @@ func (h usersHandler) updateFromJSON(ctx context.Context, id uint64, j io.Reader payload.applyTo(u) + if payload.Password != nil && *payload.Password != "" { + err = h.passSvc.SetPassword(ctx, u.ID, *payload.Password) + if err != nil { + return + } + } + return h.svc.With(ctx).Update(u) } From 3cf7cd8e2b4b572a25f31494c82a1c84a40082bd Mon Sep 17 00:00:00 2001 From: Denis Arh Date: Mon, 7 Dec 2020 19:26:23 +0100 Subject: [PATCH 09/12] Support conf. primary ID (corteza or external) --- app/options.go | 1 - app/servers.go | 28 ++++-- pkg/options/SCIM.gen.go | 11 ++- pkg/options/SCIM.yaml | 12 ++- system/scim/group_handler.go | 152 ++++++++++++++++++++----------- system/scim/routes.go | 16 +++- system/scim/user_handler.go | 169 +++++++++++++++++++++-------------- tests/system/scim_test.go | 57 +++++++++--- 8 files changed, 304 insertions(+), 142 deletions(-) diff --git a/app/options.go b/app/options.go index 3bd147a7e..6318611aa 100644 --- a/app/options.go +++ b/app/options.go @@ -28,7 +28,6 @@ type ( ) func NewOptions() *Options { - return &Options{ Environment: *options.Environment(), ActionLog: *options.ActionLog(), diff --git a/app/servers.go b/app/servers.go index ab843df8d..75e0a1d7a 100644 --- a/app/servers.go +++ b/app/servers.go @@ -14,6 +14,7 @@ import ( "github.com/go-chi/chi" "go.uber.org/zap" "net/http" + "regexp" "strings" "sync" ) @@ -82,7 +83,11 @@ func (app *CortezaApp) mountHttpRoutes(r chi.Router) { app.Log.Info("JSON REST API disabled") } - if app.Opt.SCIM.Enabled { + func() { + if !app.Opt.SCIM.Enabled { + return + } + if app.Opt.SCIM.Secret == "" { app.Log. WithOptions(zap.AddStacktrace(zap.PanicLevel)). @@ -90,9 +95,20 @@ func (app *CortezaApp) mountHttpRoutes(r chi.Router) { } var ( - baseUrl = "/" + strings.Trim(app.Opt.SCIM.BaseURL, "/") + baseUrl = "/" + strings.Trim(app.Opt.SCIM.BaseURL, "/") + extIdValidation *regexp.Regexp + err error ) + if len(app.Opt.SCIM.ExternalIdValidation) > 0 { + extIdValidation, err = regexp.Compile(app.Opt.SCIM.ExternalIdValidation) + } + + if err != nil { + app.Log.Error("failed to compile SCIM external ID validation", zap.Error(err)) + return + } + app.Log.Debug( "SCIM enabled", zap.String("baseUrl", baseUrl), @@ -100,14 +116,16 @@ func (app *CortezaApp) mountHttpRoutes(r chi.Router) { ) r.Route(baseUrl, func(r chi.Router) { - if !app.Opt.Environment.IsDevelopment() { r.Use(scim.Guard(app.Opt.SCIM)) } - scim.Routes(r) + scim.Routes(r, scim.Config{ + ExternalIdAsPrimary: app.Opt.SCIM.ExternalIdAsPrimary, + ExternalIdValidator: extIdValidation, + }) }) - } + }() if app.Opt.HTTPServer.WebappEnabled { r.Route("/"+webappBaseUrl, webapp.MakeWebappServer(app.Opt.HTTPServer)) diff --git a/pkg/options/SCIM.gen.go b/pkg/options/SCIM.gen.go index 800040438..6f1c02ccd 100644 --- a/pkg/options/SCIM.gen.go +++ b/pkg/options/SCIM.gen.go @@ -10,16 +10,19 @@ package options type ( SCIMOpt struct { - Enabled bool `env:"SCIM_ENABLED"` - BaseURL string `env:"SCIM_BASE_URL"` - Secret string `env:"SCIM_SECRET"` + Enabled bool `env:"SCIM_ENABLED"` + BaseURL string `env:"SCIM_BASE_URL"` + Secret string `env:"SCIM_SECRET"` + ExternalIdAsPrimary bool `env:"SCIM_EXTERNAL_ID_AS_PRIMARY"` + ExternalIdValidation string `env:"SCIM_EXTERNAL_ID_VALIDATION"` } ) // SCIM initializes and returns a SCIMOpt with default values func SCIM() (o *SCIMOpt) { o = &SCIMOpt{ - BaseURL: "/scim", + BaseURL: "/scim", + ExternalIdValidation: "$[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}^", } fill(o) diff --git a/pkg/options/SCIM.yaml b/pkg/options/SCIM.yaml index d2b22dbbb..086c0f62c 100644 --- a/pkg/options/SCIM.yaml +++ b/pkg/options/SCIM.yaml @@ -1,8 +1,18 @@ -name: SCIM +docs: + title: SCIM Server props: - name: enabled type: bool + description: Enable SCIM subsystem - name: baseURL default: "/scim" + description: Prefix for SCIM API endpoints - name: secret + description: Secret to use to validate requests on SCIM API endpoints + - name: externalIdAsPrimary + type: bool + description: Use external IDs in SCIM API endpoints + - name: externalIdValidation + default: "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$" + description: Validates format of external IDs. Defaults to UUID diff --git a/system/scim/group_handler.go b/system/scim/group_handler.go index 0f2ddf792..ce1d84e5a 100644 --- a/system/scim/group_handler.go +++ b/system/scim/group_handler.go @@ -9,11 +9,15 @@ import ( "github.com/go-chi/chi" "io" "net/http" + "regexp" "strconv" ) type ( groupsHandler struct { + externalIdAsPrimary bool + externalIdValidator *regexp.Regexp + svc service.RoleService sec getSecurityContextFn } @@ -21,21 +25,14 @@ type ( func (h groupsHandler) get(w http.ResponseWriter, r *http.Request) { var ( - id, _ = strconv.ParseUint(chi.URLParam(r, "id"), 10, 64) - ctx = h.sec(r) - svc = h.svc.With(ctx) + res = h.lookup(h.sec(r), chi.URLParam(r, "id"), w) ) - if id == 0 { - http.Error(w, "invalid group id", http.StatusBadRequest) + if res == nil { return } - if u, err := svc.FindByID(id); err != nil { - sendError(w, newErrorResonse(http.StatusBadRequest, err)) - } else { - send(w, http.StatusOK, newGroupResourceResponse(u)) - } + send(w, http.StatusOK, newGroupResourceResponse(res)) } func (h groupsHandler) create(w http.ResponseWriter, r *http.Request) { @@ -45,104 +42,157 @@ func (h groupsHandler) create(w http.ResponseWriter, r *http.Request) { ctx = h.sec(r) ) - if u, err := h.createFromJSON(ctx, r.Body); err != nil { - sendError(w, newErrorResonse(http.StatusBadRequest, err)) + if u, code, err := h.createFromJSON(ctx, r.Body); err != nil { + sendError(w, newErrorResonse(code, err)) } else { send(w, http.StatusCreated, newGroupResourceResponse(u)) } } -func (h groupsHandler) createFromJSON(ctx context.Context, j io.Reader) (r *types.Role, err error) { +func (h groupsHandler) createFromJSON(ctx context.Context, j io.Reader) (res *types.Role, code int, err error) { var ( svc = h.svc.With(ctx) payload = &groupResourceRequest{} ) + code = http.StatusBadRequest if err = payload.decodeJSON(j); err != nil { - return } // do we need to upsert? if payload.ExternalId != nil { - var rr types.RoleSet - rr, _, err = svc.Find(types.RoleFilter{Labels: map[string]string{groupLabel_SCIM_externalId: *payload.ExternalId}}) - if err != nil { + res, code, err = h.lookupByExternalId(ctx, *payload.ExternalId) + if err != nil && code != http.StatusNotFound { return } - - if len(rr) > 0 { - r = rr[0] - } } else if payload.Name != nil { - r, err = svc.FindByName(*payload.Name) + res, err = svc.FindByName(*payload.Name) if err != nil && !errors.Is(err, service.RoleErrNotFound()) { - return + return nil, http.StatusInternalServerError, err } } - if r == nil || r.ID == 0 { + if res == nil || res.ID == 0 { // in case when we did not find a valid group, // start from blank - r = &types.Role{} + res = &types.Role{} } - payload.applyTo(r) + payload.applyTo(res) - if r.ID > 0 { - return svc.Update(r) + if res.ID > 0 { + res, err = svc.Update(res) } else { - return svc.Create(r) + res, err = svc.Create(res) } + + if err != nil { + return nil, http.StatusInternalServerError, err + } + + return res, 0, nil } func (h groupsHandler) replace(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() var ( - ctx = h.sec(r) - groupID, _ = strconv.ParseUint(chi.URLParam(r, "id"), 10, 64) + ctx = h.sec(r) + existing = h.lookup(ctx, chi.URLParam(r, "id"), w) ) - if u, err := h.updateFromJSON(ctx, groupID, r.Body); err != nil { + if existing == nil { + return + } + + if res, err := h.updateFromJSON(ctx, existing, r.Body); err != nil { sendError(w, newErrorResonse(http.StatusBadRequest, err)) } else { - send(w, http.StatusOK, newGroupResourceResponse(u)) + send(w, http.StatusOK, newGroupResourceResponse(res)) } } -func (h groupsHandler) updateFromJSON(ctx context.Context, id uint64, j io.Reader) (r *types.Role, err error) { +func (h groupsHandler) updateFromJSON(ctx context.Context, res *types.Role, j io.Reader) (*types.Role, error) { var ( - svc = h.svc.With(ctx) payload = &groupResourceRequest{} ) - if r, err = svc.FindByID(id); err != nil { - return + if err := payload.decodeJSON(j); err != nil { + return nil, err } - if r == nil { - return nil, fmt.Errorf("refusing to update invalid group") - } + payload.applyTo(res) - if err = payload.decodeJSON(j); err != nil { - return - } - - payload.applyTo(r) - - return h.svc.With(ctx).Update(r) + return h.svc.With(ctx).Update(res) } func (h groupsHandler) delete(w http.ResponseWriter, r *http.Request) { var ( - ctx = h.sec(r) - groupID, _ = strconv.ParseUint(chi.URLParam(r, "id"), 10, 64) - svc = h.svc.With(ctx) + ctx = h.sec(r) + svc = h.svc.With(ctx) + res = h.lookup(ctx, chi.URLParam(r, "id"), w) ) - if err := svc.Delete(groupID); err != nil { + if res == nil { + return + } + + if err := svc.Delete(res.ID); err != nil { sendError(w, newErrorResonse(http.StatusBadRequest, err)) } else { w.WriteHeader(http.StatusNoContent) } } + +// loads role from request path params +// +// handles errors by writing them to response +func (h groupsHandler) lookup(ctx context.Context, id string, w http.ResponseWriter) *types.Role { + var ( + svc = h.svc.With(ctx) + ) + + if h.externalIdAsPrimary { + role, code, err := h.lookupByExternalId(ctx, id) + if err != nil { + sendError(w, newErrorResonse(code, err)) + return nil + } + + return role + } else { + resId, err := strconv.ParseUint(id, 10, 64) + if err != nil || resId == 0 { + sendError(w, newErrorResonse(http.StatusBadRequest, err)) + return nil + } + + role, err := svc.FindByID(resId) + if err != nil { + sendError(w, newErrorResonse(http.StatusBadRequest, err)) + return nil + } + + return role + } +} + +func (h groupsHandler) lookupByExternalId(ctx context.Context, id string) (r *types.Role, code int, err error) { + if h.externalIdValidator != nil && !h.externalIdValidator.MatchString(id) { + return nil, http.StatusBadRequest, fmt.Errorf("invalid external ID") + } + + rr, _, err := h.svc.With(ctx).Find(types.RoleFilter{Labels: map[string]string{groupLabel_SCIM_externalId: id}}) + if err != nil { + return nil, http.StatusInternalServerError, err + } + + switch len(rr) { + case 0: + return nil, http.StatusNotFound, fmt.Errorf("role not found") + case 1: + return rr[0], 0, nil + default: + return nil, http.StatusPreconditionFailed, fmt.Errorf("more than one role matches this externalId") + } +} diff --git a/system/scim/routes.go b/system/scim/routes.go index 074054b2b..73a21c746 100644 --- a/system/scim/routes.go +++ b/system/scim/routes.go @@ -8,6 +8,14 @@ import ( "github.com/goware/statik/fs" "go.uber.org/zap" "net/http" + "regexp" +) + +type ( + Config struct { + ExternalIdAsPrimary bool + ExternalIdValidator *regexp.Regexp + } ) var ( @@ -42,9 +50,12 @@ func Guard(opt options.SCIMOpt) func(next http.Handler) http.Handler { } } -func Routes(r chi.Router) { +func Routes(r chi.Router, cfg Config) { r.Route("/Users", func(r chi.Router) { uh := &usersHandler{ + externalIdAsPrimary: cfg.ExternalIdAsPrimary, + externalIdValidator: cfg.ExternalIdValidator, + svc: service.DefaultUser, passSvc: service.DefaultAuth, sec: getSecurityContext, @@ -58,6 +69,9 @@ func Routes(r chi.Router) { r.Route("/Groups", func(r chi.Router) { gh := &groupsHandler{ + externalIdAsPrimary: cfg.ExternalIdAsPrimary, + externalIdValidator: cfg.ExternalIdValidator, + svc: service.DefaultRole, sec: getSecurityContext, } diff --git a/system/scim/user_handler.go b/system/scim/user_handler.go index bbb00fab1..e639d1cd3 100644 --- a/system/scim/user_handler.go +++ b/system/scim/user_handler.go @@ -6,9 +6,11 @@ import ( "github.com/cortezaproject/corteza-server/pkg/errors" "github.com/cortezaproject/corteza-server/system/service" "github.com/cortezaproject/corteza-server/system/types" + "github.com/davecgh/go-spew/spew" "github.com/go-chi/chi" "io" "net/http" + "regexp" "strconv" ) @@ -18,8 +20,10 @@ type ( } usersHandler struct { + externalIdAsPrimary bool + externalIdValidator *regexp.Regexp + svc service.UserService - rleSvc service.RoleService passSvc passwordSetter sec getSecurityContextFn } @@ -27,24 +31,14 @@ type ( func (h usersHandler) get(w http.ResponseWriter, r *http.Request) { var ( - id, _ = strconv.ParseUint(chi.URLParam(r, "id"), 10, 64) - ctx = h.sec(r) - svc = h.svc.With(ctx) + res = h.lookup(h.sec(r), chi.URLParam(r, "id"), w) ) - if id == 0 { - http.Error(w, "invalid user id", http.StatusBadRequest) + if res == nil { return } - if u, err := svc.FindByID(id); err != nil { - sendError(w, newErrorResonse(http.StatusBadRequest, err)) - return - } else { - send(w, http.StatusOK, newUserResourceResponse(u)) - } - - w.WriteHeader(http.StatusOK) + send(w, http.StatusOK, newUserResourceResponse(res)) } func (h usersHandler) create(w http.ResponseWriter, r *http.Request) { @@ -54,130 +48,167 @@ func (h usersHandler) create(w http.ResponseWriter, r *http.Request) { ctx = h.sec(r) ) - if u, err := h.createFromJSON(ctx, r.Body); err != nil { - sendError(w, newErrorResonse(http.StatusBadRequest, err)) + if u, code, err := h.createFromJSON(ctx, r.Body); err != nil { + sendError(w, newErrorResonse(code, err)) } else { send(w, http.StatusCreated, newUserResourceResponse(u)) } } -func (h usersHandler) createFromJSON(ctx context.Context, j io.Reader) (u *types.User, err error) { +func (h usersHandler) createFromJSON(ctx context.Context, j io.Reader) (res *types.User, code int, err error) { var ( svc = h.svc.With(ctx) //roles = h.rleSvc.With(ctx) payload = &userResourceRequest{} ) + code = http.StatusBadRequest if err = payload.decodeJSON(j); err != nil { return } // do we need to upsert? if payload.ExternalId != nil { - var uu types.UserSet - uu, _, err = svc.Find(types.UserFilter{Labels: map[string]string{userLabel_SCIM_externalId: *payload.ExternalId}}) - if err != nil { + res, code, err = h.lookupByExternalId(ctx, *payload.ExternalId) + if err != nil && code != http.StatusNotFound { return } - - if len(uu) > 0 { - u = uu[0] - } } else if email := payload.Emails.getFirst(); email != "" { - u, err = svc.FindByEmail(email) + res, err = svc.FindByEmail(email) if err != nil && !errors.Is(err, service.UserErrNotFound()) { - return + return nil, http.StatusInternalServerError, err } } - if u == nil || !u.Valid() { + if res == nil || !res.Valid() { // in case when we did not find a valid user, // start from blank - u = &types.User{} + res = &types.User{} } - payload.applyTo(u) + payload.applyTo(res) - if u.ID > 0 { - u, err = svc.Update(u) + if res.ID > 0 { + res, err = svc.Update(res) } else { - u, err = svc.Create(u) + res, err = svc.Create(res) } if err != nil { - return - } - - if payload.Groups != nil { - // remove existing, add new - // @todo + return nil, http.StatusInternalServerError, err } if payload.Password != nil && *payload.Password != "" { - err = h.passSvc.SetPassword(ctx, u.ID, *payload.Password) + err = h.passSvc.SetPassword(ctx, res.ID, *payload.Password) if err != nil { return } } - return u, nil + return res, 0, nil } func (h usersHandler) replace(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() var ( - ctx = h.sec(r) - userID, _ = strconv.ParseUint(chi.URLParam(r, "id"), 10, 64) + ctx = h.sec(r) + existing = h.lookup(ctx, chi.URLParam(r, "id"), w) ) - if u, err := h.updateFromJSON(ctx, userID, r.Body); err != nil { + if existing == nil { + return + } + + if res, err := h.updateFromJSON(ctx, existing, r.Body); err != nil { sendError(w, newErrorResonse(http.StatusBadRequest, err)) } else { - send(w, http.StatusOK, newUserResourceResponse(u)) + send(w, http.StatusOK, newUserResourceResponse(res)) } } -func (h usersHandler) updateFromJSON(ctx context.Context, id uint64, j io.Reader) (u *types.User, err error) { +func (h usersHandler) updateFromJSON(ctx context.Context, res *types.User, j io.Reader) (*types.User, error) { var ( - svc = h.svc.With(ctx) payload = &userResourceRequest{} ) - if u, err = svc.FindByID(id); err != nil { - return + if err := payload.decodeJSON(j); err != nil { + return nil, err } - if u == nil || !u.Valid() { - return nil, fmt.Errorf("refusing to update invalid user") - } + payload.applyTo(res) - if err = payload.decodeJSON(j); err != nil { - return - } - - payload.applyTo(u) - - if payload.Password != nil && *payload.Password != "" { - err = h.passSvc.SetPassword(ctx, u.ID, *payload.Password) - if err != nil { - return - } - } - - return h.svc.With(ctx).Update(u) + return h.svc.With(ctx).Update(res) } func (h usersHandler) delete(w http.ResponseWriter, r *http.Request) { var ( - ctx = h.sec(r) - userID, _ = strconv.ParseUint(chi.URLParam(r, "id"), 10, 64) - svc = h.svc.With(ctx) + ctx = h.sec(r) + svc = h.svc.With(ctx) + res = h.lookup(ctx, chi.URLParam(r, "id"), w) ) - if err := svc.Delete(userID); err != nil { + if res == nil { + return + } + + if err := svc.Delete(res.ID); err != nil { sendError(w, newErrorResonse(http.StatusBadRequest, err)) } else { w.WriteHeader(http.StatusNoContent) } } + +// loads role from request path params +// +// handles errors by writing them to response +func (h usersHandler) lookup(ctx context.Context, id string, w http.ResponseWriter) *types.User { + var ( + svc = h.svc.With(ctx) + ) + spew.Dump(h.externalIdAsPrimary) + if h.externalIdAsPrimary { + role, code, err := h.lookupByExternalId(ctx, id) + if err != nil { + sendError(w, newErrorResonse(code, err)) + return nil + } + + return role + } else { + groupId, err := strconv.ParseUint(id, 10, 64) + if err != nil || groupId == 0 { + sendError(w, newErrorResonse(http.StatusBadRequest, err)) + return nil + } + + role, err := svc.FindByID(groupId) + if err != nil { + sendError(w, newErrorResonse(http.StatusBadRequest, err)) + return nil + } + + return role + } +} + +func (h usersHandler) lookupByExternalId(ctx context.Context, id string) (r *types.User, code int, err error) { + spew.Dump(id) + if h.externalIdValidator != nil && !h.externalIdValidator.MatchString(id) { + return nil, http.StatusBadRequest, fmt.Errorf("invalid external ID") + } + + rr, _, err := h.svc.With(ctx).Find(types.UserFilter{Labels: map[string]string{groupLabel_SCIM_externalId: id}}) + if err != nil { + return nil, http.StatusInternalServerError, err + } + + switch len(rr) { + case 0: + return nil, http.StatusNotFound, fmt.Errorf("user not found") + case 1: + return rr[0], 0, nil + default: + return nil, http.StatusPreconditionFailed, fmt.Errorf("more than one user matches this externalId") + } +} diff --git a/tests/system/scim_test.go b/tests/system/scim_test.go index a5154a893..81381f499 100644 --- a/tests/system/scim_test.go +++ b/tests/system/scim_test.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "github.com/cortezaproject/corteza-server/pkg/api/server" + "github.com/cortezaproject/corteza-server/pkg/label/types" "github.com/cortezaproject/corteza-server/pkg/logger" "github.com/cortezaproject/corteza-server/store" "github.com/cortezaproject/corteza-server/system/scim" @@ -12,23 +13,25 @@ import ( "github.com/steinfletcher/apitest" jsonpath "github.com/steinfletcher/apitest-jsonpath" "net/http" + "regexp" "testing" ) -var ( - scimRoutes chi.Router -) - // apitest basics, initialize, set handler, add auth -func (h helper) scimApiInit() *apitest.APITest { +func (h helper) scimApiInit(ffn ...func(*scim.Config)) *apitest.APITest { InitTestApp() - - if scimRoutes == nil { + var ( + scimConfig scim.Config scimRoutes = chi.NewRouter() - scimRoutes.Use(server.BaseMiddleware(false, logger.Default())...) - scim.Routes(scimRoutes) + ) + + for _, fn := range ffn { + fn(&scimConfig) } + scimRoutes.Use(server.BaseMiddleware(false, logger.Default())...) + scim.Routes(scimRoutes, scimConfig) + return apitest. New(). Handler(scimRoutes) @@ -89,7 +92,7 @@ func TestScimUserCreateNoEmail(t *testing.T) { Post("/Users"). JSON(`{"schemas":["urn:ietf:params:scim:schemas:core:2.0:User"]}`). Expect(t). - Status(http.StatusBadRequest). + Status(http.StatusInternalServerError). End() } @@ -288,3 +291,37 @@ func TestScimGroupDelete(t *testing.T) { Status(http.StatusNoContent). End() } + +func TestScimUserReplaceOnExternalId(t *testing.T) { + h := newHelper(t) + h.clearUsers() + + // creating a new user and assigning an external ID label to it + u := h.createUserWithEmail(h.randEmail()) + const externalId = `2819c223-7f76-453a-919d-413861904646` + h.a.NoError(store.UpsertLabel(h.secCtx(), service.DefaultStore, &types.Label{ + Kind: u.LabelResourceKind(), + ResourceID: u.LabelResourceID(), + Name: "SCIM_externalId", + Value: externalId, + })) + + h.scimApiInit(scimSetWithExternalId, scimSetWithUUIDValidator). + Put(fmt.Sprintf("/Users/%s", externalId)). + JSON(`{"emails":[{"value":"baz@bar.com"}],"externalId":"` + externalId + `","schemas":["urn:ietf:params:scim:schemas:core:2.0:User"]}`). + Expect(t). + Status(http.StatusOK). + End() + + u, err := store.LookupUserByID(context.Background(), service.DefaultStore, u.ID) + h.a.NoError(err) + h.a.NotNil(u) + h.a.Equal("baz@bar.com", u.Email) +} + +func scimSetWithExternalId(c *scim.Config) { + c.ExternalIdAsPrimary = true +} +func scimSetWithUUIDValidator(c *scim.Config) { + c.ExternalIdValidator = regexp.MustCompile(`^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$`) +} From 1eba292e756e3cdb610fedc07dc497da25df8b30 Mon Sep 17 00:00:00 2001 From: Denis Arh Date: Mon, 7 Dec 2020 19:55:01 +0100 Subject: [PATCH 10/12] Create users and roles with replace --- system/scim/group_handler.go | 142 +++++++++++++++++++---------------- system/scim/user_handler.go | 141 ++++++++++++++++++---------------- tests/system/scim_test.go | 6 +- 3 files changed, 156 insertions(+), 133 deletions(-) diff --git a/system/scim/group_handler.go b/system/scim/group_handler.go index ce1d84e5a..8974d19f9 100644 --- a/system/scim/group_handler.go +++ b/system/scim/group_handler.go @@ -6,8 +6,8 @@ import ( "github.com/cortezaproject/corteza-server/pkg/errors" "github.com/cortezaproject/corteza-server/system/service" "github.com/cortezaproject/corteza-server/system/types" + "github.com/davecgh/go-spew/spew" "github.com/go-chi/chi" - "io" "net/http" "regexp" "strconv" @@ -18,8 +18,9 @@ type ( externalIdAsPrimary bool externalIdValidator *regexp.Regexp - svc service.RoleService - sec getSecurityContextFn + svc service.RoleService + passSvc passwordSetter + sec getSecurityContextFn } ) @@ -39,46 +40,91 @@ func (h groupsHandler) create(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() var ( - ctx = h.sec(r) + ctx = h.sec(r) + svc = h.svc.With(ctx) + payload = &groupResourceRequest{} + err error + existing *types.Role + code = http.StatusBadRequest ) - if u, code, err := h.createFromJSON(ctx, r.Body); err != nil { + if err = payload.decodeJSON(r.Body); err != nil { sendError(w, newErrorResonse(code, err)) - } else { - send(w, http.StatusCreated, newGroupResourceResponse(u)) + return } + + { + // do we need to upsert? + if payload.ExternalId != nil { + existing, code, err = h.lookupByExternalId(ctx, *payload.ExternalId) + if err != nil && code != http.StatusNotFound { + sendError(w, newErrorResonse(code, err)) + return + } + } else if *payload.Name != "" { + existing, err = svc.FindByName(*payload.Name) + if err != nil && !errors.Is(err, service.RoleErrNotFound()) { + sendError(w, newErrorResonse(http.StatusInternalServerError, err)) + return + } + } + } + + res, code, err := h.save(ctx, payload, existing) + if err != nil { + sendError(w, newErrorResonse(code, err)) + return + } + + code = http.StatusOK + if res.UpdatedAt == nil { + code = http.StatusCreated + } + + send(w, code, newGroupResourceResponse(res)) } -func (h groupsHandler) createFromJSON(ctx context.Context, j io.Reader) (res *types.Role, code int, err error) { +func (h groupsHandler) replace(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + var ( - svc = h.svc.With(ctx) - payload = &groupResourceRequest{} + ctx = h.sec(r) + existing = h.lookup(ctx, chi.URLParam(r, "id"), w) + payload = &groupResourceRequest{} ) - code = http.StatusBadRequest - if err = payload.decodeJSON(j); err != nil { + if err := payload.decodeJSON(r.Body); err != nil { + sendError(w, newErrorResonse(http.StatusBadRequest, err)) + return } - // do we need to upsert? - if payload.ExternalId != nil { - res, code, err = h.lookupByExternalId(ctx, *payload.ExternalId) - if err != nil && code != http.StatusNotFound { - return - } - } else if payload.Name != nil { - res, err = svc.FindByName(*payload.Name) - if err != nil && !errors.Is(err, service.RoleErrNotFound()) { - return nil, http.StatusInternalServerError, err - } + res, code, err := h.save(ctx, payload, existing) + if err != nil { + sendError(w, newErrorResonse(code, err)) + return } - if res == nil || res.ID == 0 { + code = http.StatusOK + if res.UpdatedAt == nil { + code = http.StatusCreated + } + + send(w, code, newGroupResourceResponse(res)) +} + +func (h groupsHandler) save(ctx context.Context, req *groupResourceRequest, existing *types.Role) (res *types.Role, code int, err error) { + var ( + svc = h.svc.With(ctx) + ) + + if existing == nil { // in case when we did not find a valid group, // start from blank - res = &types.Role{} + existing = &types.Role{} } - payload.applyTo(res) + res = existing + req.applyTo(res) if res.ID > 0 { res, err = svc.Update(res) @@ -93,39 +139,6 @@ func (h groupsHandler) createFromJSON(ctx context.Context, j io.Reader) (res *ty return res, 0, nil } -func (h groupsHandler) replace(w http.ResponseWriter, r *http.Request) { - defer r.Body.Close() - - var ( - ctx = h.sec(r) - existing = h.lookup(ctx, chi.URLParam(r, "id"), w) - ) - - if existing == nil { - return - } - - if res, err := h.updateFromJSON(ctx, existing, r.Body); err != nil { - sendError(w, newErrorResonse(http.StatusBadRequest, err)) - } else { - send(w, http.StatusOK, newGroupResourceResponse(res)) - } -} - -func (h groupsHandler) updateFromJSON(ctx context.Context, res *types.Role, j io.Reader) (*types.Role, error) { - var ( - payload = &groupResourceRequest{} - ) - - if err := payload.decodeJSON(j); err != nil { - return nil, err - } - - payload.applyTo(res) - - return h.svc.With(ctx).Update(res) -} - func (h groupsHandler) delete(w http.ResponseWriter, r *http.Request) { var ( ctx = h.sec(r) @@ -161,13 +174,13 @@ func (h groupsHandler) lookup(ctx context.Context, id string, w http.ResponseWri return role } else { - resId, err := strconv.ParseUint(id, 10, 64) - if err != nil || resId == 0 { + groupId, err := strconv.ParseUint(id, 10, 64) + if err != nil || groupId == 0 { sendError(w, newErrorResonse(http.StatusBadRequest, err)) return nil } - role, err := svc.FindByID(resId) + role, err := svc.FindByID(groupId) if err != nil { sendError(w, newErrorResonse(http.StatusBadRequest, err)) return nil @@ -178,6 +191,7 @@ func (h groupsHandler) lookup(ctx context.Context, id string, w http.ResponseWri } func (h groupsHandler) lookupByExternalId(ctx context.Context, id string) (r *types.Role, code int, err error) { + spew.Dump(id) if h.externalIdValidator != nil && !h.externalIdValidator.MatchString(id) { return nil, http.StatusBadRequest, fmt.Errorf("invalid external ID") } @@ -189,10 +203,10 @@ func (h groupsHandler) lookupByExternalId(ctx context.Context, id string) (r *ty switch len(rr) { case 0: - return nil, http.StatusNotFound, fmt.Errorf("role not found") + return nil, http.StatusNotFound, fmt.Errorf("group not found") case 1: return rr[0], 0, nil default: - return nil, http.StatusPreconditionFailed, fmt.Errorf("more than one role matches this externalId") + return nil, http.StatusPreconditionFailed, fmt.Errorf("more than one group matches this externalId") } } diff --git a/system/scim/user_handler.go b/system/scim/user_handler.go index e639d1cd3..8f581e8f1 100644 --- a/system/scim/user_handler.go +++ b/system/scim/user_handler.go @@ -8,7 +8,6 @@ import ( "github.com/cortezaproject/corteza-server/system/types" "github.com/davecgh/go-spew/spew" "github.com/go-chi/chi" - "io" "net/http" "regexp" "strconv" @@ -45,48 +44,91 @@ func (h usersHandler) create(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() var ( - ctx = h.sec(r) + ctx = h.sec(r) + svc = h.svc.With(ctx) + payload = &userResourceRequest{} + err error + existing *types.User + code = http.StatusBadRequest ) - if u, code, err := h.createFromJSON(ctx, r.Body); err != nil { + if err = payload.decodeJSON(r.Body); err != nil { sendError(w, newErrorResonse(code, err)) - } else { - send(w, http.StatusCreated, newUserResourceResponse(u)) - } -} - -func (h usersHandler) createFromJSON(ctx context.Context, j io.Reader) (res *types.User, code int, err error) { - var ( - svc = h.svc.With(ctx) - //roles = h.rleSvc.With(ctx) - payload = &userResourceRequest{} - ) - - code = http.StatusBadRequest - if err = payload.decodeJSON(j); err != nil { return } - // do we need to upsert? - if payload.ExternalId != nil { - res, code, err = h.lookupByExternalId(ctx, *payload.ExternalId) - if err != nil && code != http.StatusNotFound { - return - } - } else if email := payload.Emails.getFirst(); email != "" { - res, err = svc.FindByEmail(email) - if err != nil && !errors.Is(err, service.UserErrNotFound()) { - return nil, http.StatusInternalServerError, err + { + // do we need to upsert? + if payload.ExternalId != nil { + existing, code, err = h.lookupByExternalId(ctx, *payload.ExternalId) + if err != nil && code != http.StatusNotFound { + sendError(w, newErrorResonse(code, err)) + return + } + } else if email := payload.Emails.getFirst(); email != "" { + existing, err = svc.FindByEmail(email) + if err != nil && !errors.Is(err, service.UserErrNotFound()) { + sendError(w, newErrorResonse(http.StatusInternalServerError, err)) + return + } } } - if res == nil || !res.Valid() { + res, code, err := h.save(ctx, payload, existing) + if err != nil { + sendError(w, newErrorResonse(code, err)) + return + } + + code = http.StatusOK + if res.UpdatedAt == nil { + code = http.StatusCreated + } + + send(w, code, newUserResourceResponse(res)) +} + +func (h usersHandler) replace(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + + var ( + ctx = h.sec(r) + existing = h.lookup(ctx, chi.URLParam(r, "id"), w) + payload = &userResourceRequest{} + ) + + if err := payload.decodeJSON(r.Body); err != nil { + sendError(w, newErrorResonse(http.StatusBadRequest, err)) + return + } + + res, code, err := h.save(ctx, payload, existing) + if err != nil { + sendError(w, newErrorResonse(code, err)) + return + } + + code = http.StatusOK + if res.UpdatedAt == nil { + code = http.StatusCreated + } + + send(w, code, newUserResourceResponse(res)) +} + +func (h usersHandler) save(ctx context.Context, req *userResourceRequest, existing *types.User) (res *types.User, code int, err error) { + var ( + svc = h.svc.With(ctx) + ) + + if existing == nil || !existing.Valid() { // in case when we did not find a valid user, // start from blank - res = &types.User{} + existing = &types.User{} } - payload.applyTo(res) + res = existing + req.applyTo(res) if res.ID > 0 { res, err = svc.Update(res) @@ -98,8 +140,8 @@ func (h usersHandler) createFromJSON(ctx context.Context, j io.Reader) (res *typ return nil, http.StatusInternalServerError, err } - if payload.Password != nil && *payload.Password != "" { - err = h.passSvc.SetPassword(ctx, res.ID, *payload.Password) + if req.Password != nil && *req.Password != "" { + err = h.passSvc.SetPassword(ctx, res.ID, *req.Password) if err != nil { return } @@ -108,39 +150,6 @@ func (h usersHandler) createFromJSON(ctx context.Context, j io.Reader) (res *typ return res, 0, nil } -func (h usersHandler) replace(w http.ResponseWriter, r *http.Request) { - defer r.Body.Close() - - var ( - ctx = h.sec(r) - existing = h.lookup(ctx, chi.URLParam(r, "id"), w) - ) - - if existing == nil { - return - } - - if res, err := h.updateFromJSON(ctx, existing, r.Body); err != nil { - sendError(w, newErrorResonse(http.StatusBadRequest, err)) - } else { - send(w, http.StatusOK, newUserResourceResponse(res)) - } -} - -func (h usersHandler) updateFromJSON(ctx context.Context, res *types.User, j io.Reader) (*types.User, error) { - var ( - payload = &userResourceRequest{} - ) - - if err := payload.decodeJSON(j); err != nil { - return nil, err - } - - payload.applyTo(res) - - return h.svc.With(ctx).Update(res) -} - func (h usersHandler) delete(w http.ResponseWriter, r *http.Request) { var ( ctx = h.sec(r) @@ -166,7 +175,7 @@ func (h usersHandler) lookup(ctx context.Context, id string, w http.ResponseWrit var ( svc = h.svc.With(ctx) ) - spew.Dump(h.externalIdAsPrimary) + if h.externalIdAsPrimary { role, code, err := h.lookupByExternalId(ctx, id) if err != nil { diff --git a/tests/system/scim_test.go b/tests/system/scim_test.go index 81381f499..87db631dc 100644 --- a/tests/system/scim_test.go +++ b/tests/system/scim_test.go @@ -106,7 +106,7 @@ func TestScimUserCreateOverwrite(t *testing.T) { Post("/Users"). JSON(`{"userName":"UPDATED","emails":[{"value":"foo@bar.com"}],"schemas":["urn:ietf:params:scim:schemas:core:2.0:User"]}`). Expect(t). - Status(http.StatusCreated). + Status(http.StatusOK). End() u, err := store.LookupUserByEmail(context.Background(), service.DefaultStore, "foo@bar.com") @@ -133,7 +133,7 @@ func TestScimUserExternalID(t *testing.T) { Post("/Users"). JSON(`{"userName":"baz","emails":[{"value":"baz@bar.com"}],"externalId":"foo42","schemas":["urn:ietf:params:scim:schemas:core:2.0:User"]}`). Expect(t). - Status(http.StatusCreated). + Status(http.StatusOK). End() u, err = store.LookupUserByEmail(context.Background(), service.DefaultStore, "baz@bar.com") @@ -253,7 +253,7 @@ func TestScimGroupExternalId(t *testing.T) { Post("/Groups"). JSON(`{"schemas":["urn:ietf:params:scim:schemas:core:2.0:Group"],"displayName":"bar","externalId":"grp42"}`). Expect(t). - Status(http.StatusCreated). + Status(http.StatusOK). End() u, err = store.LookupRoleByName(context.Background(), service.DefaultStore, "bar") From 5dc3fb45c8db3b77d68426fec4a6d63ef7d755cb Mon Sep 17 00:00:00 2001 From: Denis Arh Date: Mon, 7 Dec 2020 20:12:10 +0100 Subject: [PATCH 11/12] Refactor error handling --- system/scim/gen_response.go | 11 +++++- system/scim/group_handler.go | 73 +++++++++++++++++++----------------- system/scim/http.go | 12 +++++- system/scim/user_handler.go | 66 +++++++++++++++++--------------- 4 files changed, 93 insertions(+), 69 deletions(-) diff --git a/system/scim/gen_response.go b/system/scim/gen_response.go index 33e32dc2b..c4419170b 100644 --- a/system/scim/gen_response.go +++ b/system/scim/gen_response.go @@ -1,6 +1,7 @@ package scim import ( + "fmt" "github.com/cortezaproject/corteza-server/system/types" "net/http" "time" @@ -45,7 +46,11 @@ func newGroupMetaResponse(u *types.Role) *metaResponse { return rsp } -func newErrorResonse(httpStatus int, err error) *errorResponse { +func newErrorfResponse(httpStatus int, format string, aa ...interface{}) *errorResponse { + return newErrorResponse(httpStatus, fmt.Errorf(format, aa...)) +} + +func newErrorResponse(httpStatus int, err error) *errorResponse { if httpStatus == 0 { httpStatus = http.StatusInternalServerError } @@ -61,3 +66,7 @@ func newErrorResonse(httpStatus int, err error) *errorResponse { return er } + +func (e *errorResponse) Error() string { + return e.Detail +} diff --git a/system/scim/group_handler.go b/system/scim/group_handler.go index 8974d19f9..ccf98b5a2 100644 --- a/system/scim/group_handler.go +++ b/system/scim/group_handler.go @@ -45,43 +45,42 @@ func (h groupsHandler) create(w http.ResponseWriter, r *http.Request) { payload = &groupResourceRequest{} err error existing *types.Role - code = http.StatusBadRequest ) if err = payload.decodeJSON(r.Body); err != nil { - sendError(w, newErrorResonse(code, err)) + sendError(w, newErrorResponse(http.StatusBadRequest, err)) return } { // do we need to upsert? if payload.ExternalId != nil { - existing, code, err = h.lookupByExternalId(ctx, *payload.ExternalId) - if err != nil && code != http.StatusNotFound { - sendError(w, newErrorResonse(code, err)) + existing, err = h.lookupByExternalId(ctx, *payload.ExternalId) + if err != nil { + sendError(w, err) return } } else if *payload.Name != "" { existing, err = svc.FindByName(*payload.Name) if err != nil && !errors.Is(err, service.RoleErrNotFound()) { - sendError(w, newErrorResonse(http.StatusInternalServerError, err)) + sendError(w, err) return } } } - res, code, err := h.save(ctx, payload, existing) + res, err := h.save(ctx, payload, existing) if err != nil { - sendError(w, newErrorResonse(code, err)) + sendError(w, err) return } - code = http.StatusOK + status := http.StatusOK if res.UpdatedAt == nil { - code = http.StatusCreated + status = http.StatusCreated } - send(w, code, newGroupResourceResponse(res)) + send(w, status, newGroupResourceResponse(res)) } func (h groupsHandler) replace(w http.ResponseWriter, r *http.Request) { @@ -94,25 +93,25 @@ func (h groupsHandler) replace(w http.ResponseWriter, r *http.Request) { ) if err := payload.decodeJSON(r.Body); err != nil { - sendError(w, newErrorResonse(http.StatusBadRequest, err)) + sendError(w, newErrorResponse(http.StatusBadRequest, err)) return } - res, code, err := h.save(ctx, payload, existing) + res, err := h.save(ctx, payload, existing) if err != nil { - sendError(w, newErrorResonse(code, err)) + sendError(w, err) return } - code = http.StatusOK + status := http.StatusOK if res.UpdatedAt == nil { - code = http.StatusCreated + status = http.StatusCreated } - send(w, code, newGroupResourceResponse(res)) + send(w, status, newGroupResourceResponse(res)) } -func (h groupsHandler) save(ctx context.Context, req *groupResourceRequest, existing *types.Role) (res *types.Role, code int, err error) { +func (h groupsHandler) save(ctx context.Context, req *groupResourceRequest, existing *types.Role) (res *types.Role, err error) { var ( svc = h.svc.With(ctx) ) @@ -133,10 +132,10 @@ func (h groupsHandler) save(ctx context.Context, req *groupResourceRequest, exis } if err != nil { - return nil, http.StatusInternalServerError, err + return nil, newErrorResponse(http.StatusInternalServerError, err) } - return res, 0, nil + return res, nil } func (h groupsHandler) delete(w http.ResponseWriter, r *http.Request) { @@ -151,7 +150,7 @@ func (h groupsHandler) delete(w http.ResponseWriter, r *http.Request) { } if err := svc.Delete(res.ID); err != nil { - sendError(w, newErrorResonse(http.StatusBadRequest, err)) + sendError(w, newErrorResponse(http.StatusBadRequest, err)) } else { w.WriteHeader(http.StatusNoContent) } @@ -166,23 +165,27 @@ func (h groupsHandler) lookup(ctx context.Context, id string, w http.ResponseWri ) if h.externalIdAsPrimary { - role, code, err := h.lookupByExternalId(ctx, id) + res, err := h.lookupByExternalId(ctx, id) if err != nil { - sendError(w, newErrorResonse(code, err)) + sendError(w, err) return nil } - return role + if res == nil { + sendError(w, newErrorResponse(http.StatusNotFound, fmt.Errorf("group not found"))) + } + + return res } else { - groupId, err := strconv.ParseUint(id, 10, 64) - if err != nil || groupId == 0 { - sendError(w, newErrorResonse(http.StatusBadRequest, err)) + id, err := strconv.ParseUint(id, 10, 64) + if err != nil || id == 0 { + sendError(w, newErrorResponse(http.StatusBadRequest, err)) return nil } - role, err := svc.FindByID(groupId) + role, err := svc.FindByID(id) if err != nil { - sendError(w, newErrorResonse(http.StatusBadRequest, err)) + sendError(w, newErrorResponse(http.StatusBadRequest, err)) return nil } @@ -190,23 +193,23 @@ func (h groupsHandler) lookup(ctx context.Context, id string, w http.ResponseWri } } -func (h groupsHandler) lookupByExternalId(ctx context.Context, id string) (r *types.Role, code int, err error) { +func (h groupsHandler) lookupByExternalId(ctx context.Context, id string) (r *types.Role, err error) { spew.Dump(id) if h.externalIdValidator != nil && !h.externalIdValidator.MatchString(id) { - return nil, http.StatusBadRequest, fmt.Errorf("invalid external ID") + return nil, newErrorfResponse(http.StatusBadRequest, "invalid external ID") } rr, _, err := h.svc.With(ctx).Find(types.RoleFilter{Labels: map[string]string{groupLabel_SCIM_externalId: id}}) if err != nil { - return nil, http.StatusInternalServerError, err + return nil, newErrorResponse(http.StatusInternalServerError, err) } switch len(rr) { case 0: - return nil, http.StatusNotFound, fmt.Errorf("group not found") + return nil, nil case 1: - return rr[0], 0, nil + return rr[0], nil default: - return nil, http.StatusPreconditionFailed, fmt.Errorf("more than one group matches this externalId") + return nil, newErrorfResponse(http.StatusPreconditionFailed, "more than one group matches this externalId") } } diff --git a/system/scim/http.go b/system/scim/http.go index d48a8b74f..cdae99c45 100644 --- a/system/scim/http.go +++ b/system/scim/http.go @@ -14,6 +14,14 @@ func send(w http.ResponseWriter, status int, payload interface{}) { } } -func sendError(w http.ResponseWriter, err *errorResponse) { - send(w, err.Status, err) +func sendError(w http.ResponseWriter, err error) { + var ( + status = http.StatusInternalServerError + ) + + if er, ok := err.(*errorResponse); ok { + status = er.Status + } + + send(w, status, err) } diff --git a/system/scim/user_handler.go b/system/scim/user_handler.go index 8f581e8f1..d7d020d54 100644 --- a/system/scim/user_handler.go +++ b/system/scim/user_handler.go @@ -53,39 +53,39 @@ func (h usersHandler) create(w http.ResponseWriter, r *http.Request) { ) if err = payload.decodeJSON(r.Body); err != nil { - sendError(w, newErrorResonse(code, err)) + sendError(w, newErrorResponse(code, err)) return } { // do we need to upsert? if payload.ExternalId != nil { - existing, code, err = h.lookupByExternalId(ctx, *payload.ExternalId) - if err != nil && code != http.StatusNotFound { - sendError(w, newErrorResonse(code, err)) + existing, err = h.lookupByExternalId(ctx, *payload.ExternalId) + if err != nil { + sendError(w, newErrorResponse(code, err)) return } } else if email := payload.Emails.getFirst(); email != "" { existing, err = svc.FindByEmail(email) if err != nil && !errors.Is(err, service.UserErrNotFound()) { - sendError(w, newErrorResonse(http.StatusInternalServerError, err)) + sendError(w, newErrorResponse(http.StatusInternalServerError, err)) return } } } - res, code, err := h.save(ctx, payload, existing) + res, err := h.save(ctx, payload, existing) if err != nil { - sendError(w, newErrorResonse(code, err)) + sendError(w, err) return } - code = http.StatusOK + status := http.StatusOK if res.UpdatedAt == nil { - code = http.StatusCreated + status = http.StatusCreated } - send(w, code, newUserResourceResponse(res)) + send(w, status, newUserResourceResponse(res)) } func (h usersHandler) replace(w http.ResponseWriter, r *http.Request) { @@ -98,25 +98,25 @@ func (h usersHandler) replace(w http.ResponseWriter, r *http.Request) { ) if err := payload.decodeJSON(r.Body); err != nil { - sendError(w, newErrorResonse(http.StatusBadRequest, err)) + sendError(w, newErrorResponse(http.StatusBadRequest, err)) return } - res, code, err := h.save(ctx, payload, existing) + res, err := h.save(ctx, payload, existing) if err != nil { - sendError(w, newErrorResonse(code, err)) + sendError(w, err) return } - code = http.StatusOK + status := http.StatusOK if res.UpdatedAt == nil { - code = http.StatusCreated + status = http.StatusCreated } - send(w, code, newUserResourceResponse(res)) + send(w, status, newUserResourceResponse(res)) } -func (h usersHandler) save(ctx context.Context, req *userResourceRequest, existing *types.User) (res *types.User, code int, err error) { +func (h usersHandler) save(ctx context.Context, req *userResourceRequest, existing *types.User) (res *types.User, err error) { var ( svc = h.svc.With(ctx) ) @@ -137,7 +137,7 @@ func (h usersHandler) save(ctx context.Context, req *userResourceRequest, existi } if err != nil { - return nil, http.StatusInternalServerError, err + return nil, err } if req.Password != nil && *req.Password != "" { @@ -147,7 +147,7 @@ func (h usersHandler) save(ctx context.Context, req *userResourceRequest, existi } } - return res, 0, nil + return res, nil } func (h usersHandler) delete(w http.ResponseWriter, r *http.Request) { @@ -162,7 +162,7 @@ func (h usersHandler) delete(w http.ResponseWriter, r *http.Request) { } if err := svc.Delete(res.ID); err != nil { - sendError(w, newErrorResonse(http.StatusBadRequest, err)) + sendError(w, newErrorResponse(http.StatusBadRequest, err)) } else { w.WriteHeader(http.StatusNoContent) } @@ -177,23 +177,27 @@ func (h usersHandler) lookup(ctx context.Context, id string, w http.ResponseWrit ) if h.externalIdAsPrimary { - role, code, err := h.lookupByExternalId(ctx, id) + res, err := h.lookupByExternalId(ctx, id) if err != nil { - sendError(w, newErrorResonse(code, err)) + sendError(w, err) return nil } - return role + if res == nil { + sendError(w, newErrorResponse(http.StatusNotFound, fmt.Errorf("user not found"))) + } + + return res } else { groupId, err := strconv.ParseUint(id, 10, 64) if err != nil || groupId == 0 { - sendError(w, newErrorResonse(http.StatusBadRequest, err)) + sendError(w, newErrorResponse(http.StatusBadRequest, err)) return nil } role, err := svc.FindByID(groupId) if err != nil { - sendError(w, newErrorResonse(http.StatusBadRequest, err)) + sendError(w, newErrorResponse(http.StatusBadRequest, err)) return nil } @@ -201,23 +205,23 @@ func (h usersHandler) lookup(ctx context.Context, id string, w http.ResponseWrit } } -func (h usersHandler) lookupByExternalId(ctx context.Context, id string) (r *types.User, code int, err error) { +func (h usersHandler) lookupByExternalId(ctx context.Context, id string) (r *types.User, err error) { spew.Dump(id) if h.externalIdValidator != nil && !h.externalIdValidator.MatchString(id) { - return nil, http.StatusBadRequest, fmt.Errorf("invalid external ID") + return nil, newErrorfResponse(http.StatusBadRequest, "invalid external ID") } rr, _, err := h.svc.With(ctx).Find(types.UserFilter{Labels: map[string]string{groupLabel_SCIM_externalId: id}}) if err != nil { - return nil, http.StatusInternalServerError, err + return nil, newErrorResponse(http.StatusInternalServerError, err) } switch len(rr) { case 0: - return nil, http.StatusNotFound, fmt.Errorf("user not found") + return nil, nil case 1: - return rr[0], 0, nil + return rr[0], nil default: - return nil, http.StatusPreconditionFailed, fmt.Errorf("more than one user matches this externalId") + return nil, newErrorfResponse(http.StatusPreconditionFailed, "more than one user matches this externalId") } } From d46630c7be40a43b1d6c367e8bb325b5d80c089a Mon Sep 17 00:00:00 2001 From: Denis Arh Date: Tue, 8 Dec 2020 07:51:13 +0100 Subject: [PATCH 12/12] Implement SCIM PATCH for group (role) membership --- pkg/options/SCIM.gen.go | 2 +- system/scim/group_handler.go | 120 +++++++++++++++++++++++++++++++++- system/scim/http.go | 5 ++ system/scim/patch_payloads.go | 37 +++++++++++ system/scim/routes.go | 6 +- system/scim/user_handler.go | 10 +-- tests/system/main_test.go | 12 ++++ tests/system/role_test.go | 17 +++++ tests/system/scim_test.go | 71 ++++++++++++++++++-- 9 files changed, 263 insertions(+), 17 deletions(-) create mode 100644 system/scim/patch_payloads.go diff --git a/pkg/options/SCIM.gen.go b/pkg/options/SCIM.gen.go index 6f1c02ccd..a93fa5ff4 100644 --- a/pkg/options/SCIM.gen.go +++ b/pkg/options/SCIM.gen.go @@ -22,7 +22,7 @@ type ( func SCIM() (o *SCIMOpt) { o = &SCIMOpt{ BaseURL: "/scim", - ExternalIdValidation: "$[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}^", + ExternalIdValidation: "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$", } fill(o) diff --git a/system/scim/group_handler.go b/system/scim/group_handler.go index ccf98b5a2..5fb61ce51 100644 --- a/system/scim/group_handler.go +++ b/system/scim/group_handler.go @@ -4,9 +4,9 @@ import ( "context" "fmt" "github.com/cortezaproject/corteza-server/pkg/errors" + "github.com/cortezaproject/corteza-server/store" "github.com/cortezaproject/corteza-server/system/service" "github.com/cortezaproject/corteza-server/system/types" - "github.com/davecgh/go-spew/spew" "github.com/go-chi/chi" "net/http" "regexp" @@ -19,7 +19,7 @@ type ( externalIdValidator *regexp.Regexp svc service.RoleService - passSvc passwordSetter + userSvc service.UserService sec getSecurityContextFn } ) @@ -111,6 +111,121 @@ func (h groupsHandler) replace(w http.ResponseWriter, r *http.Request) { send(w, status, newGroupResourceResponse(res)) } +// patches group +// +// only supports adding and removing members +func (h groupsHandler) patch(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + + var ( + ctx = h.sec(r) + svc = h.svc.With(ctx) + res = h.lookup(ctx, chi.URLParam(r, "id"), w) + payload = &operationsRequest{} + ) + + if res == nil { + return + } + + if err := payload.decodeJSON(r.Body); err != nil { + sendError(w, newErrorResponse(http.StatusBadRequest, err)) + return + } + + var ( + u *types.User + ops = make([]func() error, 0, len(payload.Operations)) + err error + memberships = make(map[uint64]bool) + ) + + { + // collect all existing memberships into a simple map + // to ensure we dont step on our feet (too much) + // + // this is not 100% bulletproof for concurrent modifications + mm, _, err := store.SearchRoleMembers(ctx, service.DefaultStore, types.RoleMemberFilter{RoleID: res.ID}) + if err != nil { + sendError(w, err) + return + } + + for _, m := range mm { + memberships[m.UserID] = true + } + } + + // validate and collect operations + for _, op := range payload.Operations { + if op.Path != "members" { + // allow only "members" path + sendError(w, newErrorfResponse(http.StatusBadRequest, "unsupported path: %q", op.Path)) + return + } + + // iterate through operation's values, load user and schedule op + for _, userExternalId := range op.Value { + u, err = lookupUserByExternalId(ctx, h.userSvc, h.externalIdValidator, userExternalId.Value) + if err != nil { + sendError(w, err) + return + } + + if u == nil { + sendError(w, newErrorfResponse(http.StatusBadRequest, "no such user: %q", userExternalId.Value)) + return + } + + // making sure u is not overwritten + // in the next iteration + memberId := u.ID + + switch op.Operation { + case patchOpAdd: + // support for add operation, + // check if there members already exist + ops = append(ops, func() error { + if memberships[memberId] { + // already added + return nil + } + + memberships[memberId] = true + return svc.MemberAdd(res.ID, memberId) + }) + + case patchOpRemove: + // support for remove operation, + // check if there members are missing + ops = append(ops, func() error { + if !memberships[memberId] { + // already removed + return nil + } + + delete(memberships, memberId) + return svc.MemberRemove(res.ID, memberId) + }) + + default: + sendError(w, newErrorfResponse(http.StatusBadRequest, "unsupported operation: %q", op.Operation)) + return + } + } + } + + // run all scheduled ops + for _, op := range ops { + if err = op(); err != nil { + sendError(w, err) + return + } + } + + send(w, http.StatusNoContent, nil) +} + func (h groupsHandler) save(ctx context.Context, req *groupResourceRequest, existing *types.Role) (res *types.Role, err error) { var ( svc = h.svc.With(ctx) @@ -194,7 +309,6 @@ func (h groupsHandler) lookup(ctx context.Context, id string, w http.ResponseWri } func (h groupsHandler) lookupByExternalId(ctx context.Context, id string) (r *types.Role, err error) { - spew.Dump(id) if h.externalIdValidator != nil && !h.externalIdValidator.MatchString(id) { return nil, newErrorfResponse(http.StatusBadRequest, "invalid external ID") } diff --git a/system/scim/http.go b/system/scim/http.go index cdae99c45..847daf294 100644 --- a/system/scim/http.go +++ b/system/scim/http.go @@ -9,6 +9,11 @@ import ( func send(w http.ResponseWriter, status int, payload interface{}) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) + + if status == http.StatusNoContent || payload == nil { + return + } + if err := json.NewEncoder(w).Encode(payload); err != nil { log.Error("could not encode payload", zap.Error(err)) } diff --git a/system/scim/patch_payloads.go b/system/scim/patch_payloads.go new file mode 100644 index 000000000..7df2ea577 --- /dev/null +++ b/system/scim/patch_payloads.go @@ -0,0 +1,37 @@ +package scim + +import ( + "encoding/json" + "fmt" + "io" +) + +const ( + urnPatchOp = "urn:ietf:params:scim:schemas:core:2.0:PatchOp" + patchOpAdd = "add" + patchOpRemove = "remove" +) + +type ( + // very crud operations support + operationsRequest struct { + Schemas []string `json:"schemas"` + Operations []operationRequest `json:"Operations"` + } + + operationRequest struct { + Operation string `json:"op"` + Path string `json:"path"` + Value []struct { + Value string `json:"value"` + } `json:"value"` + } +) + +func (req *operationsRequest) decodeJSON(r io.Reader) error { + if err := json.NewDecoder(r).Decode(req); err != nil { + return fmt.Errorf("could not decode operations payload: %w", err) + } + + return nil +} diff --git a/system/scim/routes.go b/system/scim/routes.go index 73a21c746..a0cc6afc0 100644 --- a/system/scim/routes.go +++ b/system/scim/routes.go @@ -72,13 +72,15 @@ func Routes(r chi.Router, cfg Config) { externalIdAsPrimary: cfg.ExternalIdAsPrimary, externalIdValidator: cfg.ExternalIdValidator, - svc: service.DefaultRole, - sec: getSecurityContext, + svc: service.DefaultRole, + userSvc: service.DefaultUser, + sec: getSecurityContext, } r.Get("/{id}", gh.get) r.Post("/", gh.create) r.Put("/{id}", gh.replace) + r.Patch("/{id}", gh.patch) r.Delete("/{id}", gh.delete) }) } diff --git a/system/scim/user_handler.go b/system/scim/user_handler.go index d7d020d54..ededd4177 100644 --- a/system/scim/user_handler.go +++ b/system/scim/user_handler.go @@ -6,7 +6,6 @@ import ( "github.com/cortezaproject/corteza-server/pkg/errors" "github.com/cortezaproject/corteza-server/system/service" "github.com/cortezaproject/corteza-server/system/types" - "github.com/davecgh/go-spew/spew" "github.com/go-chi/chi" "net/http" "regexp" @@ -206,12 +205,15 @@ func (h usersHandler) lookup(ctx context.Context, id string, w http.ResponseWrit } func (h usersHandler) lookupByExternalId(ctx context.Context, id string) (r *types.User, err error) { - spew.Dump(id) - if h.externalIdValidator != nil && !h.externalIdValidator.MatchString(id) { + return lookupUserByExternalId(ctx, h.svc, h.externalIdValidator, id) +} + +func lookupUserByExternalId(ctx context.Context, svc service.UserService, v *regexp.Regexp, id string) (r *types.User, err error) { + if v != nil && !v.MatchString(id) { return nil, newErrorfResponse(http.StatusBadRequest, "invalid external ID") } - rr, _, err := h.svc.With(ctx).Find(types.UserFilter{Labels: map[string]string{groupLabel_SCIM_externalId: id}}) + rr, _, err := svc.With(ctx).Find(types.UserFilter{Labels: map[string]string{userLabel_SCIM_externalId: id}}) if err != nil { return nil, newErrorResponse(http.StatusInternalServerError, err) } diff --git a/tests/system/main_test.go b/tests/system/main_test.go index b8ebb69a4..baba1c4c8 100644 --- a/tests/system/main_test.go +++ b/tests/system/main_test.go @@ -9,10 +9,13 @@ import ( "github.com/cortezaproject/corteza-server/pkg/cli" "github.com/cortezaproject/corteza-server/pkg/eventbus" "github.com/cortezaproject/corteza-server/pkg/id" + label "github.com/cortezaproject/corteza-server/pkg/label" + ltype "github.com/cortezaproject/corteza-server/pkg/label/types" "github.com/cortezaproject/corteza-server/pkg/logger" "github.com/cortezaproject/corteza-server/pkg/objstore/plain" "github.com/cortezaproject/corteza-server/pkg/rand" "github.com/cortezaproject/corteza-server/pkg/rbac" + "github.com/cortezaproject/corteza-server/store" "github.com/cortezaproject/corteza-server/store/sqlite3" "github.com/cortezaproject/corteza-server/system/rest" "github.com/cortezaproject/corteza-server/system/service" @@ -165,3 +168,12 @@ func (h helper) noError(err error) { h.a.NoError(err) } + +func (h helper) setLabel(res label.LabeledResource, name, value string) { + h.a.NoError(store.UpsertLabel(h.secCtx(), service.DefaultStore, <ype.Label{ + Kind: res.LabelResourceKind(), + ResourceID: res.LabelResourceID(), + Name: name, + Value: value, + })) +} diff --git a/tests/system/role_test.go b/tests/system/role_test.go index d62fb753f..896a53906 100644 --- a/tests/system/role_test.go +++ b/tests/system/role_test.go @@ -20,6 +20,23 @@ func (h helper) clearRoles() { h.noError(store.TruncateRoles(context.Background(), service.DefaultStore)) } +func (h helper) clearRoleMembers() { + h.noError(store.TruncateRoleMembers(context.Background(), service.DefaultStore)) +} + +func (h helper) createRole(res *types.Role) *types.Role { + if res.ID == 0 { + res.ID = id.Next() + } + + if res.CreatedAt.IsZero() { + res.CreatedAt = time.Now() + } + + h.a.NoError(service.DefaultStore.CreateRole(context.Background(), res)) + return res +} + func (h helper) repoMakeRole(ss ...string) *types.Role { var r = &types.Role{ ID: id.Next(), diff --git a/tests/system/scim_test.go b/tests/system/scim_test.go index 87db631dc..68e796e20 100644 --- a/tests/system/scim_test.go +++ b/tests/system/scim_test.go @@ -4,11 +4,11 @@ import ( "context" "fmt" "github.com/cortezaproject/corteza-server/pkg/api/server" - "github.com/cortezaproject/corteza-server/pkg/label/types" "github.com/cortezaproject/corteza-server/pkg/logger" "github.com/cortezaproject/corteza-server/store" "github.com/cortezaproject/corteza-server/system/scim" "github.com/cortezaproject/corteza-server/system/service" + "github.com/cortezaproject/corteza-server/system/types" "github.com/go-chi/chi" "github.com/steinfletcher/apitest" jsonpath "github.com/steinfletcher/apitest-jsonpath" @@ -299,12 +299,7 @@ func TestScimUserReplaceOnExternalId(t *testing.T) { // creating a new user and assigning an external ID label to it u := h.createUserWithEmail(h.randEmail()) const externalId = `2819c223-7f76-453a-919d-413861904646` - h.a.NoError(store.UpsertLabel(h.secCtx(), service.DefaultStore, &types.Label{ - Kind: u.LabelResourceKind(), - ResourceID: u.LabelResourceID(), - Name: "SCIM_externalId", - Value: externalId, - })) + h.setLabel(u, "SCIM_externalId", externalId) h.scimApiInit(scimSetWithExternalId, scimSetWithUUIDValidator). Put(fmt.Sprintf("/Users/%s", externalId)). @@ -319,6 +314,68 @@ func TestScimUserReplaceOnExternalId(t *testing.T) { h.a.Equal("baz@bar.com", u.Email) } +func TestScimPatchingGroupMembership(t *testing.T) { + h := newHelper(t) + h.clearUsers() + h.clearRoles() + h.clearRoleMembers() + + isMember := func(r *types.Role, u *types.User) bool { + mm, _, err := store.SearchRoleMembers(h.secCtx(), service.DefaultStore, types.RoleMemberFilter{RoleID: r.ID, UserID: u.ID}) + h.a.NoError(err) + return len(mm) > 0 + } + + const ( + user1Id = `00000000-0000-0000-0000-000000000001` + user2Id = `00000000-0000-0000-0000-000000000002` + groupId = `00000000-0000-0000-0001-000000000001` + ) + + // creating a new user and assigning an external ID label to it + u1 := h.createUserWithEmail(h.randEmail()) + h.setLabel(u1, "SCIM_externalId", user1Id) + + u2 := h.createUserWithEmail(h.randEmail()) + h.setLabel(u2, "SCIM_externalId", user2Id) + + r := h.createRole(&types.Role{}) + h.setLabel(r, "SCIM_externalId", groupId) + + h.a.False(isMember(r, u1)) + h.a.False(isMember(r, u2)) + + // add only user #1 + h.scimApiInit(scimSetWithExternalId, scimSetWithUUIDValidator). + Patch(fmt.Sprintf("/Groups/%s", groupId)). + JSON(fmt.Sprintf( + `{"Operations":[{"op":"add","path":"members","value":[{"value":%q}]}],"schemas":["urn:ietf:params:scim:schemas:core:2.0:PatchOp"]}`, + user1Id, + )). + Expect(t). + Status(http.StatusNoContent). + End() + + h.a.True(isMember(r, u1)) + h.a.False(isMember(r, u2)) + + // remove user #1, add user #2 + h.scimApiInit(scimSetWithExternalId, scimSetWithUUIDValidator). + Patch(fmt.Sprintf("/Groups/%s", groupId)). + JSON(fmt.Sprintf( + `{"Operations":[{"op":"add","path":"members","value":[{"value":%q}]},{"op":"remove","path":"members","value":[{"value":%q}]}],"schemas":["urn:ietf:params:scim:schemas:core:2.0:PatchOp"]}`, + user2Id, + user1Id, + )). + Expect(t). + Status(http.StatusNoContent). + End() + + h.a.False(isMember(r, u1)) + h.a.True(isMember(r, u2)) + +} + func scimSetWithExternalId(c *scim.Config) { c.ExternalIdAsPrimary = true }