Merge branch '2020.12.x-feature-scim' into 2020.12.x

This commit is contained in:
Denis Arh
2020-12-16 11:13:27 +01:00
25 changed files with 2542 additions and 2 deletions
+2 -1
View File
@@ -23,11 +23,11 @@ type (
Websocket options.WebsocketOpt
Eventbus options.EventbusOpt
Federation options.FederationOpt
SCIM options.SCIMOpt
}
)
func NewOptions() *Options {
return &Options{
Environment: *options.Environment(),
ActionLog: *options.ActionLog(),
@@ -46,5 +46,6 @@ func NewOptions() *Options {
Websocket: *options.Websocket(),
Eventbus: *options.Eventbus(),
Federation: *options.Federation(),
SCIM: *options.SCIM(),
}
}
+47
View File
@@ -7,11 +7,14 @@ 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"
"regexp"
"strings"
"sync"
)
@@ -80,6 +83,50 @@ func (app *CortezaApp) mountHttpRoutes(r chi.Router) {
app.Log.Info("JSON REST API disabled")
}
func() {
if !app.Opt.SCIM.Enabled {
return
}
if app.Opt.SCIM.Secret == "" {
app.Log.
WithOptions(zap.AddStacktrace(zap.PanicLevel)).
Error("SCIM secret empty")
}
var (
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),
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, scim.Config{
ExternalIdAsPrimary: app.Opt.SCIM.ExternalIdAsPrimary,
ExternalIdValidator: extIdValidation,
})
})
}()
if app.Opt.HTTPServer.WebappEnabled {
r.Route("/"+webappBaseUrl, webapp.MakeWebappServer(app.Opt.HTTPServer))
+40
View File
@@ -0,0 +1,40 @@
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"`
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",
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)
// 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
}
+18
View File
@@ -0,0 +1,18 @@
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
+11
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
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.
@@ -0,0 +1,6 @@
{
"id": "Group",
"name": "Group",
"endpoint": "/Groups",
"schema": "urn:ietf:params:scim:schemas:core:2.0:Group"
}
@@ -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
}
]
}
+128
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
}
}
}
]
}
]
}
@@ -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": {}
}
}
}
]
}
@@ -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"
}
]
}
]
}
+738
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
+72
View File
@@ -0,0 +1,72 @@
package scim
import (
"fmt"
"github.com/cortezaproject/corteza-server/system/types"
"net/http"
"time"
)
type (
metaResponse struct {
ResourceType string `json:"resourceType"`
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 {
rsp := &metaResponse{
ResourceType: "User",
Created: u.CreatedAt,
LastModified: u.UpdatedAt,
}
return rsp
}
func newGroupMetaResponse(u *types.Role) *metaResponse {
rsp := &metaResponse{
ResourceType: "Group",
Created: u.CreatedAt,
LastModified: u.UpdatedAt,
}
return rsp
}
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
}
er := &errorResponse{
Schemas: []string{urnError},
Status: httpStatus,
}
if err != nil {
er.Detail = err.Error()
}
return er
}
func (e *errorResponse) Error() string {
return e.Detail
}
+329
View File
@@ -0,0 +1,329 @@
package scim
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/go-chi/chi"
"net/http"
"regexp"
"strconv"
)
type (
groupsHandler struct {
externalIdAsPrimary bool
externalIdValidator *regexp.Regexp
svc service.RoleService
userSvc service.UserService
sec getSecurityContextFn
}
)
func (h groupsHandler) get(w http.ResponseWriter, r *http.Request) {
var (
res = h.lookup(h.sec(r), chi.URLParam(r, "id"), w)
)
if res == nil {
return
}
send(w, http.StatusOK, newGroupResourceResponse(res))
}
func (h groupsHandler) create(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
var (
ctx = h.sec(r)
svc = h.svc.With(ctx)
payload = &groupResourceRequest{}
err error
existing *types.Role
)
if err = payload.decodeJSON(r.Body); err != nil {
sendError(w, newErrorResponse(http.StatusBadRequest, err))
return
}
{
// do we need to upsert?
if payload.ExternalId != nil {
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, err)
return
}
}
}
res, err := h.save(ctx, payload, existing)
if err != nil {
sendError(w, err)
return
}
status := http.StatusOK
if res.UpdatedAt == nil {
status = http.StatusCreated
}
send(w, status, newGroupResourceResponse(res))
}
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)
payload = &groupResourceRequest{}
)
if err := payload.decodeJSON(r.Body); err != nil {
sendError(w, newErrorResponse(http.StatusBadRequest, err))
return
}
res, err := h.save(ctx, payload, existing)
if err != nil {
sendError(w, err)
return
}
status := http.StatusOK
if res.UpdatedAt == nil {
status = http.StatusCreated
}
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)
)
if existing == nil {
// in case when we did not find a valid group,
// start from blank
existing = &types.Role{}
}
res = existing
req.applyTo(res)
if res.ID > 0 {
res, err = svc.Update(res)
} else {
res, err = svc.Create(res)
}
if err != nil {
return nil, newErrorResponse(http.StatusInternalServerError, err)
}
return res, nil
}
func (h groupsHandler) delete(w http.ResponseWriter, r *http.Request) {
var (
ctx = h.sec(r)
svc = h.svc.With(ctx)
res = h.lookup(ctx, chi.URLParam(r, "id"), w)
)
if res == nil {
return
}
if err := svc.Delete(res.ID); err != nil {
sendError(w, newErrorResponse(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 {
res, err := h.lookupByExternalId(ctx, id)
if err != nil {
sendError(w, err)
return nil
}
if res == nil {
sendError(w, newErrorResponse(http.StatusNotFound, fmt.Errorf("group not found")))
}
return res
} else {
id, err := strconv.ParseUint(id, 10, 64)
if err != nil || id == 0 {
sendError(w, newErrorResponse(http.StatusBadRequest, err))
return nil
}
role, err := svc.FindByID(id)
if err != nil {
sendError(w, newErrorResponse(http.StatusBadRequest, err))
return nil
}
return role
}
}
func (h groupsHandler) lookupByExternalId(ctx context.Context, id string) (r *types.Role, err error) {
if h.externalIdValidator != nil && !h.externalIdValidator.MatchString(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, newErrorResponse(http.StatusInternalServerError, err)
}
switch len(rr) {
case 0:
return nil, nil
case 1:
return rr[0], nil
default:
return nil, newErrorfResponse(http.StatusPreconditionFailed, "more than one group matches this externalId")
}
}
+61
View File
@@ -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)
}
}
+32
View File
@@ -0,0 +1,32 @@
package scim
import (
"encoding/json"
"go.uber.org/zap"
"net/http"
)
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))
}
}
func sendError(w http.ResponseWriter, err error) {
var (
status = http.StatusInternalServerError
)
if er, ok := err.(*errorResponse); ok {
status = er.Status
}
send(w, status, err)
}
+37
View File
@@ -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
}
+86
View File
@@ -0,0 +1,86 @@
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"
"regexp"
)
type (
Config struct {
ExternalIdAsPrimary bool
ExternalIdValidator *regexp.Regexp
}
)
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, 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,
}
r.Get("/{id}", uh.get)
r.Post("/", uh.create)
r.Put("/{id}", uh.replace)
r.Delete("/{id}", uh.delete)
})
r.Route("/Groups", func(r chi.Router) {
gh := &groupsHandler{
externalIdAsPrimary: cfg.ExternalIdAsPrimary,
externalIdValidator: cfg.ExternalIdValidator,
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)
})
}
+17
View File
@@ -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())
}
+229
View File
@@ -0,0 +1,229 @@
package scim
import (
"context"
"fmt"
"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"
"net/http"
"regexp"
"strconv"
)
type (
passwordSetter interface {
SetPassword(context.Context, uint64, string) error
}
usersHandler struct {
externalIdAsPrimary bool
externalIdValidator *regexp.Regexp
svc service.UserService
passSvc passwordSetter
sec getSecurityContextFn
}
)
func (h usersHandler) get(w http.ResponseWriter, r *http.Request) {
var (
res = h.lookup(h.sec(r), chi.URLParam(r, "id"), w)
)
if res == nil {
return
}
send(w, http.StatusOK, newUserResourceResponse(res))
}
func (h usersHandler) create(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
var (
ctx = h.sec(r)
svc = h.svc.With(ctx)
payload = &userResourceRequest{}
err error
existing *types.User
code = http.StatusBadRequest
)
if err = payload.decodeJSON(r.Body); err != nil {
sendError(w, newErrorResponse(code, err))
return
}
{
// do we need to upsert?
if payload.ExternalId != nil {
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, newErrorResponse(http.StatusInternalServerError, err))
return
}
}
}
res, err := h.save(ctx, payload, existing)
if err != nil {
sendError(w, err)
return
}
status := http.StatusOK
if res.UpdatedAt == nil {
status = http.StatusCreated
}
send(w, status, 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, newErrorResponse(http.StatusBadRequest, err))
return
}
res, err := h.save(ctx, payload, existing)
if err != nil {
sendError(w, err)
return
}
status := http.StatusOK
if res.UpdatedAt == nil {
status = http.StatusCreated
}
send(w, status, newUserResourceResponse(res))
}
func (h usersHandler) save(ctx context.Context, req *userResourceRequest, existing *types.User) (res *types.User, 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
existing = &types.User{}
}
res = existing
req.applyTo(res)
if res.ID > 0 {
res, err = svc.Update(res)
} else {
res, err = svc.Create(res)
}
if err != nil {
return nil, err
}
if req.Password != nil && *req.Password != "" {
err = h.passSvc.SetPassword(ctx, res.ID, *req.Password)
if err != nil {
return
}
}
return res, nil
}
func (h usersHandler) delete(w http.ResponseWriter, r *http.Request) {
var (
ctx = h.sec(r)
svc = h.svc.With(ctx)
res = h.lookup(ctx, chi.URLParam(r, "id"), w)
)
if res == nil {
return
}
if err := svc.Delete(res.ID); err != nil {
sendError(w, newErrorResponse(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)
)
if h.externalIdAsPrimary {
res, err := h.lookupByExternalId(ctx, id)
if err != nil {
sendError(w, err)
return nil
}
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, newErrorResponse(http.StatusBadRequest, err))
return nil
}
role, err := svc.FindByID(groupId)
if err != nil {
sendError(w, newErrorResponse(http.StatusBadRequest, err))
return nil
}
return role
}
}
func (h usersHandler) lookupByExternalId(ctx context.Context, id string) (r *types.User, err error) {
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 := svc.With(ctx).Find(types.UserFilter{Labels: map[string]string{userLabel_SCIM_externalId: id}})
if err != nil {
return nil, newErrorResponse(http.StatusInternalServerError, err)
}
switch len(rr) {
case 0:
return nil, nil
case 1:
return rr[0], nil
default:
return nil, newErrorfResponse(http.StatusPreconditionFailed, "more than one user matches this externalId")
}
}
+121
View File
@@ -0,0 +1,121 @@
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"`
}
userGroupMembershipRequest struct {
Value string `json:"value"`
}
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"`
Groups []*userGroupMembershipRequest `json:"groups,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)
}
}
+12 -1
View File
@@ -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"
@@ -78,7 +81,6 @@ func InitTestApp() {
eventbus.Set(eventBus)
return nil
})
}
if r == nil {
@@ -166,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, &ltype.Label{
Kind: res.LabelResourceKind(),
ResourceID: res.LabelResourceID(),
Name: name,
Value: value,
}))
}
+17
View File
@@ -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(),
+384
View File
@@ -0,0 +1,384 @@
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/system/types"
"github.com/go-chi/chi"
"github.com/steinfletcher/apitest"
jsonpath "github.com/steinfletcher/apitest-jsonpath"
"net/http"
"regexp"
"testing"
)
// apitest basics, initialize, set handler, add auth
func (h helper) scimApiInit(ffn ...func(*scim.Config)) *apitest.APITest {
InitTestApp()
var (
scimConfig scim.Config
scimRoutes = chi.NewRouter()
)
for _, fn := range ffn {
fn(&scimConfig)
}
scimRoutes.Use(server.BaseMiddleware(false, logger.Default())...)
scim.Routes(scimRoutes, scimConfig)
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(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().
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).
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 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.StatusInternalServerError).
End()
}
func TestScimUserCreateOverwrite(t *testing.T) {
h := newHelper(t)
h.clearUsers()
u := h.createUserWithEmail("foo@bar.com")
h.scimApiInit().
Post("/Users").
JSON(`{"userName":"UPDATED","emails":[{"value":"foo@bar.com"}],"schemas":["urn:ietf:params:scim:schemas:core:2.0:User"]}`).
Expect(t).
Status(http.StatusOK).
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().
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).
End()
u, err := store.LookupUserByEmail(context.Background(), service.DefaultStore, "foo@bar.com")
h.a.NoError(err)
h.a.Equal("foo", u.Username)
h.scimApiInit().
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.StatusOK).
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()
u := h.createUserWithEmail(h.randEmail())
h.scimApiInit().
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.StatusOK).
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 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()
u := h.createUserWithEmail(h.randEmail())
h.scimApiInit().
Delete(fmt.Sprintf("/Users/%d", u.ID)).
Expect(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(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().
Post("/Groups").
JSON(`{"schemas":["urn:ietf:params:scim:schemas:core:2.0:Group"],"displayName":"foo"}`).
Expect(t).
Status(http.StatusCreated).
End()
u, err := store.LookupRoleByName(context.Background(), service.DefaultStore, "foo")
h.a.NoError(err)
h.a.Equal("foo", u.Name)
}
func TestScimGroupExternalId(t *testing.T) {
h := newHelper(t)
h.clearRoles()
h.scimApiInit().
Post("/Groups").
JSON(`{"schemas":["urn:ietf:params:scim:schemas:core:2.0:Group"],"displayName":"foo","externalId":"grp42"}`).
Expect(t).
Status(http.StatusCreated).
End()
u, err := store.LookupRoleByName(context.Background(), service.DefaultStore, "foo")
h.a.NoError(err)
h.a.Equal("foo", u.Name)
h.scimApiInit().
Post("/Groups").
JSON(`{"schemas":["urn:ietf:params:scim:schemas:core:2.0:Group"],"displayName":"bar","externalId":"grp42"}`).
Expect(t).
Status(http.StatusOK).
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()
u := h.repoMakeRole()
h.scimApiInit().
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()
}
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.setLabel(u, "SCIM_externalId", 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 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
}
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}$`)
}