3
0

Add basic SCIM implementation

This commit is contained in:
Denis Arh
2020-11-27 08:23:26 +01:00
parent f9c1fa2680
commit e47cc3269c
20 changed files with 1594 additions and 1 deletions

View File

@@ -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(),
}
}

View File

@@ -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))

37
pkg/options/SCIM.gen.go Normal file
View File

@@ -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
}

8
pkg/options/SCIM.yaml Normal file
View File

@@ -0,0 +1,8 @@
name: SCIM
props:
- name: enabled
type: bool
- name: baseURL
default: "/scim"
- name: secret

11
system/scim/Makefile Normal file
View File

@@ -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

6
system/scim/README.adoc Normal file
View File

@@ -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.

View File

@@ -0,0 +1,6 @@
{
"id": "Group",
"name": "Group",
"endpoint": "/Groups",
"schema": "urn:ietf:params:scim:schemas:core:2.0:Group"
}

View File

@@ -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
}
]
}

View File

@@ -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
}
}
}
]
}
]
}

View File

@@ -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": {}
}
}
}
]
}

View File

@@ -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"
}
]
}
]
}

View File

@@ -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"
}
]
}
]
}

File diff suppressed because one or more lines are too long

View File

@@ -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
}

14
system/scim/http.go Normal file
View File

@@ -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))
}
}

54
system/scim/routes.go Normal file
View File

@@ -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)
})
}

144
system/scim/user_handler.go Normal file
View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -78,7 +78,6 @@ func InitTestApp() {
eventbus.Set(eventBus)
return nil
})
}
if r == nil {

129
tests/system/scim_test.go Normal file
View File

@@ -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()
}