scimgateway
Version:
Using SCIM protocol as a gateway for user provisioning to other endpoints
513 lines (455 loc) • 21.2 kB
text/typescript
// =================================================================================
// File: plugin-scim.js
//
// Author: Jarle Elshaug
//
// Purpose: REST Webservice user-provisioning using REST endpoint "loki"
//
// Prereq: plugin-loki is up and running
//
// Supported attributes:
//
// GlobalUser Template Scim Endpoint
// -----------------------------------------------------------------------------------------------
// User name %AC% userName userName
// Suspended - active active
// Password %P% password password
// First Name %UF% name.givenName name.givenName
// Last Name %UL% name.familyName name.familyName
// Full Name %UN% name.formatted name.formatted
// Job title %UT% title title
// Email %UE% (Emails, type=Work) emails.work emails [type eq work]
// Phone %UP% (Phone Numbers, type=Work) phoneNumbers.work phoneNumbers [type eq work]
// Company %UCOMP% (Entitlements, type=Company) entitlements.company entitlements [type eq company]
//
// =================================================================================
// for supporting nodejs running scimgateway package directly, using dynamic import instead of: import { ScimGateway } from 'scimgateway'
// scimgateway also inclues HelperRest: import { ScimGateway, HelperRest } from 'scimgateway'
// start - mandatory plugin initialization
const ScimGateway: typeof import('scimgateway').ScimGateway = await (async () => {
try {
return (await import('scimgateway')).ScimGateway
} catch (err) {
const source = './scimgateway.ts'
return (await import(source)).ScimGateway
}
})()
const HelperRest: typeof import('scimgateway').HelperRest = await (async () => {
try {
return (await import('scimgateway')).HelperRest
} catch (err) {
const source = './scimgateway.ts'
return (await import(source)).HelperRest
}
})()
const scimgateway = new ScimGateway()
const config = scimgateway.getConfig()
scimgateway.authPassThroughAllowed = false
// end - mandatory plugin initialization
const helper = new HelperRest(scimgateway)
// =================================================
// getUsers
// =================================================
scimgateway.getUsers = async (baseEntity, getObj, attributes, ctx) => {
const action = 'getUsers'
scimgateway.logDebug(baseEntity, `handling ${action} getObj=${getObj ? JSON.stringify(getObj) : ''} attributes=${attributes}`)
const method = 'GET'
let path
const body = null
// start mandatory if-else logic
if (getObj.operator) {
if (getObj.operator === 'eq' && ['id', 'userName', 'externalId'].includes(getObj.attribute)) {
// mandatory - unique filtering - single unique user to be returned - correspond to getUser() in versions < 4.x.x
if (getObj.attribute === 'id') path = `/Users/${getObj.value}?attributes=${attributes.join()}` // GET /Users/bjensen?attributes=
else path = `/Users?filter=${getObj.attribute} eq "${getObj.value}"${(attributes.length > 0) ? '&attributes=' + attributes.join() : ''}` // GET /Users?filter=userName eq "bjensen"&attributes=userName,active,name.givenName,name.familyName,name.formatted,title,emails,phoneNumbers,entitlements
} else if (getObj.operator === 'eq' && getObj.attribute === 'group.value') {
// optional - only used when groups are member of users, not default behavior - correspond to getGroupUsers() in versions < 4.x.x
path = `/Users?filter=${getObj.attribute} eq "${getObj.value}"${(attributes.length > 0) ? '&attributes=' + attributes.join() : ''}`
} else {
// optional - simpel filtering
path = `/Users?filter=${getObj.attribute} ${getObj.operator} "${getObj.value}"${(attributes.length > 0) ? '&attributes=' + attributes.join() : ''}`
}
} else if (getObj.rawFilter) {
// optional - advanced filtering having and/or/not - use getObj.rawFilter
throw new Error(`${action} error: not supporting advanced filtering: ${getObj.rawFilter}`)
} else {
// mandatory - no filtering (!getObj.operator && !getObj.rawFilter) - all users to be returned - correspond to exploreUsers() in versions < 4.x.x
path = `/Users${(attributes.length > 0 ? '?attributes=' + attributes.join() : '')}`
}
// end mandatory if-else logic
if (!path) throw new Error(`${action} error: mandatory if-else logic not fully implemented`)
const ret: any = { // itemsPerPage will be set by scimgateway
Resources: [],
totalResults: null,
}
try {
const response = await helper.doRequest(baseEntity, method, path, body, ctx)
if (response.statusCode < 200 || response.statusCode > 299) {
throw new Error(`${response.statusMessage} - ${JSON.stringify(response.body)}`)
} else if (!response.body) {
throw new Error('got empty response on REST request')
}
let responseArr: any = []
if (Array.isArray(response.body.Resources)) responseArr = response.body.Resources
else if (!response.body.Resources) {
if (Array.isArray(response.body)) responseArr = response.body
else if (typeof (response.body) === 'object' && Object.keys(response.body).length > 0) responseArr = [response.body]
}
if (!getObj.startIndex && !getObj.count) { // client request without paging
getObj.startIndex = 1
getObj.count = responseArr.length
}
for (let i = 0; i < responseArr.length && (i + 1 - getObj.startIndex) < getObj.count; ++i) {
const userObj: any = responseArr[i]
if (!userObj || Object.keys(userObj).length < 1) continue
const objWorkEmail = scimgateway.getArrayObject(userObj, 'emails', 'work') // {"type": "work", "value": "bjensen@example.com"}
const objWorkPhone = scimgateway.getArrayObject(userObj, 'phoneNumbers', 'work')
const objCompanyEntitlement = scimgateway.getArrayObject(userObj, 'entitlements', 'company')
let arrEmail
let arrPhone
let arrEntitlement
if (objWorkEmail) arrEmail = [objWorkEmail]
if (objWorkPhone) arrPhone = [objWorkPhone]
if (objCompanyEntitlement) arrEntitlement = [objCompanyEntitlement]
const retObj = { // scimgateway strips attributes according to attributes list and will also auto include groups if needed
id: userObj.id ? userObj.id : undefined, // id and userName is mandatory and most often set to the same value
userName: userObj.userName ? userObj.userName : undefined,
active: userObj.active === true || userObj.active === false ? userObj.active : undefined,
name: {
givenName: userObj.name && userObj.name.givenName ? userObj.name.givenName : undefined,
familyName: userObj.name && userObj.name.familyName ? userObj.name.familyName : undefined,
formatted: userObj.name && userObj.name.formatted ? userObj.name.formatted : undefined,
},
title: userObj.title ? userObj.title : undefined,
emails: arrEmail,
phoneNumbers: arrPhone,
entitlements: arrEntitlement,
}
ret.Resources.push(retObj)
}
ret.totalResults = responseArr.length // not needed if client or endpoint do not support paging
return ret
} catch (err: any) {
throw new Error(`${action} error: ${err.message}`)
}
}
// =================================================
// createUser
// =================================================
scimgateway.createUser = async (baseEntity, userObj, ctx) => {
const action = 'createUser'
scimgateway.logDebug(baseEntity, `handling ${action} userObj=${JSON.stringify(userObj)}`)
if (!userObj.name) userObj.name = {}
if (!userObj.emails) userObj.emails = { work: {} }
if (!userObj.phoneNumbers) userObj.phoneNumbers = { work: {} }
if (!userObj.entitlements) userObj.entitlements = { company: {} }
const arrEmail: string[] = []
const arrPhone: string[] = []
const arrEntitlement: string[] = []
if (userObj.emails.work.value) arrEmail.push(userObj.emails.work)
if (userObj.phoneNumbers.work.value) arrPhone.push(userObj.phoneNumbers.work)
if (userObj.entitlements.company.value) arrEntitlement.push(userObj.entitlements.company)
const method = 'POST'
const path = '/Users'
const body = {
userName: userObj.userName,
active: userObj.active || true,
password: userObj.password || null,
name: {
givenName: userObj.name.givenName || null,
familyName: userObj.name.familyName || null,
formatted: userObj.name.formatted || null,
},
title: userObj.title || '',
emails: (arrEmail.length > 0) ? arrEmail : null,
phoneNumbers: (arrPhone.length > 0) ? arrPhone : null,
entitlements: (arrEntitlement.length > 0) ? arrEntitlement : null,
}
try {
const response = await helper.doRequest(baseEntity, method, path, body, ctx)
if (response.statusCode < 200 || response.statusCode > 299) {
throw new Error(`${response.statusMessage} - ${JSON.stringify(response.body)}`)
}
return null
} catch (err: any) {
throw new Error(`${action} error: ${err.message}`)
}
}
// =================================================
// deleteUser
// =================================================
scimgateway.deleteUser = async (baseEntity, id, ctx) => {
const action = 'deleteUser'
scimgateway.logDebug(baseEntity, `handling ${action} id=${id}`)
const method = 'DELETE'
const path = `/Users/${id}`
const body = null
try {
const response = await helper.doRequest(baseEntity, method, path, body, ctx)
if (response.statusCode < 200 || response.statusCode > 299) {
throw new Error(`${response.statusMessage} - ${JSON.stringify(response.body)}`)
}
return null
} catch (err: any) {
throw new Error(`${action} error: ${err.message}`)
}
}
// =================================================
// modifyUser
// =================================================
scimgateway.modifyUser = async (baseEntity, id, attrObj, ctx) => {
const action = 'modifyUser'
scimgateway.logDebug(baseEntity, `handling ${action} id=${id} attrObj=${JSON.stringify(attrObj)}`)
if (!attrObj.name) attrObj.name = {}
if (!attrObj.emails) attrObj.emails = {}
if (!attrObj.phoneNumbers) attrObj.phoneNumbers = {}
if (!attrObj.entitlements) attrObj.entitlements = {}
const arrEmail: string[] = []
const arrPhone: string[] = []
const arrEntitlement: string[] = []
if (attrObj.emails.work) {
if (!attrObj.emails.work.type) attrObj.emails.work.type = 'work'
arrEmail.push(attrObj.emails.work)
}
if (attrObj.phoneNumbers.work) {
if (!attrObj.phoneNumbers.work.type) attrObj.phoneNumbers.work.type = 'work'
arrPhone.push(attrObj.phoneNumbers.work)
}
if (attrObj.entitlements.company) {
if (!attrObj.entitlements.company.type) attrObj.entitlements.company.type = 'work'
arrEntitlement.push(attrObj.entitlements.company)
}
const method = 'PATCH'
const path = `/Users/${id}`
let body: any = {} // { userName: id }
if (attrObj.active === true) body.active = true
else if (attrObj.active === false) body.active = false
if (attrObj.password) body.password = attrObj.password
if (attrObj.name.givenName || attrObj.name.givenName === '') {
if (!body.name) body.name = {}
body.name.givenName = attrObj.name.givenName
}
if (attrObj.name.familyName || attrObj.name.familyName === '') {
if (!body.name) body.name = {}
body.name.familyName = attrObj.name.familyName
}
if (attrObj.name.formatted || attrObj.name.formatted === '') {
if (!body.name) body.name = {}
body.name.formatted = attrObj.name.formatted
}
if (attrObj.title || attrObj.title === '') {
body.title = attrObj.title
}
if (arrEmail.length > 0) {
body.emails = arrEmail
}
if (arrPhone.length > 0) {
body.phoneNumbers = arrPhone
}
if (arrEntitlement.length > 0) {
body.entitlements = arrEntitlement
}
if (!config.entity[baseEntity].scimVersion || config.entity[baseEntity].scimVersion !== '1.1') { // scim 2.0 endpoint
body = {
Operations: [
{
op: 'replace',
value: body,
},
],
}
}
try {
const response = await helper.doRequest(baseEntity, method, path, body, ctx)
if (response.statusCode < 200 || response.statusCode > 299) {
throw new Error(`${response.statusMessage} - ${JSON.stringify(response.body)}`)
}
return null
} catch (err: any) {
throw new Error(`${action} error: ${err.message}`)
}
}
// =================================================
// getGroups
// =================================================
scimgateway.getGroups = async (baseEntity, getObj, attributes, ctx) => {
const action = 'getGroups'
scimgateway.logDebug(baseEntity, `handling ${action} getObj=${getObj ? JSON.stringify(getObj) : ''} attributes=${attributes}`)
const method = 'GET'
let path
const body = null
// mandatory if-else logic - start
if (getObj.operator) {
if (getObj.operator === 'eq' && ['id', 'displayName', 'externalId'].includes(getObj.attribute)) {
// mandatory - unique filtering - single unique user to be returned - correspond to getUser() in versions < 4.x.x
if (getObj.attribute === 'id') path = `/Groups/${getObj.value}?attributes=${attributes.join()}` // GET /Users/bjensen?attributes=
else path = `/Groups?filter=${getObj.attribute} eq "${getObj.value}"${(attributes.length > 0) ? '&attributes=' + attributes.join() : ''}` // GET /Users?filter=userName eq "bjensen"&attributes=userName,active,name.givenName,name.familyName,name.formatted,title,emails,phoneNumbers,entitlements
} else if (getObj.operator === 'eq' && getObj.attribute === 'members.value') {
// mandatory - return all groups the user 'id' (getObj.value) is member of - correspond to getGroupMembers() in versions < 4.x.x
// Resources = [{ id: <id-group>> , displayName: <displayName-group>, members [{value: <id-user>}] }]
path = `/Groups?filter=${getObj.attribute} eq "${getObj.value}"${(attributes.length > 0) ? '&attributes=' + attributes.join() : ''}`
} else {
// optional - simpel filtering
path = `/Groups?filter=${getObj.attribute} eq "${getObj.value}"${(attributes.length > 0) ? '&attributes=' + attributes.join() : ''}`
}
} else if (getObj.rawFilter) {
// optional - advanced filtering having and/or/not - use getObj.rawFilter
throw new Error(`${action} error: not supporting advanced filtering: ${getObj.rawFilter}`)
} else {
// mandatory - no filtering (!getObj.operator && !getObj.rawFilter) - all groups to be returned - correspond to exploreGroups() in versions < 4.x.x
path = `/Groups${(attributes.length > 0 ? '?attributes=' + attributes.join() : '')}`
}
// mandatory if-else logic - end
if (!path) throw new Error(`${action} error: mandatory if-else logic not fully implemented`)
const ret: any = { // itemsPerPage will be set by scimgateway
Resources: [],
totalResults: null,
}
try {
const response = await helper.doRequest(baseEntity, method, path, body, ctx)
if (response.statusCode < 200 || response.statusCode > 299) {
throw new Error(`${response.statusMessage} - ${JSON.stringify(response.body)}`)
} else if (!response.body) {
throw new Error('got empty response on REST request')
}
let responseArr: any = []
if (Array.isArray(response.body.Resources)) responseArr = response.body.Resources
else if (!response.body.Resources) {
if (Array.isArray(response.body)) responseArr = response.body
else if (typeof (response.body) === 'object' && Object.keys(response.body).length > 0) responseArr = [response.body]
}
if (!getObj.startIndex && !getObj.count) { // client request without paging
getObj.startIndex = 1
getObj.count = responseArr.length
}
for (let i = 0; i < responseArr.length && (i + 1 - getObj.startIndex) < getObj.count; ++i) {
const groupObj = responseArr[i]
if (!groupObj || Object.keys(groupObj).length < 1) continue
const retObj = { // scimgateway strips attributes according to attributes list
id: groupObj.id ? groupObj.id : undefined, // id and displayName is mandatory and most often set to the same value
displayName: groupObj.displayName ? groupObj.displayName : undefined,
members: Array.isArray(groupObj.members) ? groupObj.members : undefined,
}
ret.Resources.push(retObj)
}
ret.totalResults = responseArr.length // not needed if client or endpoint do not support paging
return ret
} catch (err: any) {
throw new Error(`${action} error: ${err.message}`)
}
}
// =================================================
// createGroup
// =================================================
scimgateway.createGroup = async (baseEntity, groupObj, ctx) => {
const action = 'createGroup'
scimgateway.logDebug(baseEntity, `handling ${action} groupObj=${JSON.stringify(groupObj)}`)
const method = 'POST'
const path = '/Groups'
const body = { displayName: groupObj.displayName }
try {
const response = await helper.doRequest(baseEntity, method, path, body, ctx)
if (response.statusCode < 200 || response.statusCode > 299) {
throw new Error(`${response.statusMessage} - ${JSON.stringify(response.body)}`)
}
return null
} catch (err: any) {
throw new Error(`${action} error: ${err.message}`)
}
}
// =================================================
// deleteGroup
// =================================================
scimgateway.deleteGroup = async (baseEntity, id, ctx) => {
const action = 'deleteGroup'
scimgateway.logDebug(baseEntity, `handling ${action} id=${id}`)
const method = 'DELETE'
const path = `/Groups/${id}`
const body = null
try {
const response = await helper.doRequest(baseEntity, method, path, body, ctx)
if (response.statusCode < 200 || response.statusCode > 299) {
throw new Error(`${response.statusMessage} - ${JSON.stringify(response.body)}`)
}
return null
} catch (err: any) {
throw new Error(`${action} error: ${err.message}`)
}
}
// =================================================
// modifyGroup
// =================================================
scimgateway.modifyGroup = async (baseEntity, id, attrObj, ctx) => {
const action = 'modifyGroup'
scimgateway.logDebug(baseEntity, `handling ${action} id=${id} attrObj=${JSON.stringify(attrObj)}`)
if (!attrObj.members) {
throw new Error(`${action} error: only supports modification of members`)
}
if (!Array.isArray(attrObj.members)) {
throw new Error(`${action} error: ${JSON.stringify(attrObj)} - correct syntax is { "members": [...] }`)
}
let body: any = {}
if (config.entity[baseEntity].scimVersion && config.entity[baseEntity].scimVersion === '1.1') { // scim v1.1 endpoint
body = { members: [] }
attrObj.members.forEach(function (el) {
if (el.operation && el.operation === 'delete') { // delete member from group
// PATCH = /Groups/Admins Body = {"members":[{"operation":"delete","value":"bjensen"}]}
body.members.push({ operation: 'delete', value: el.value })
} else { // add member to group/
// PATCH = /Groups/Admins Body = {"members":[{"value":"bjensen"}]
body.members.push({ value: el.value })
}
})
} else { // scim 2.0 endpoint
const addValues: any = []
const removeValues: any = []
attrObj.members.forEach(function (el) {
if (el.operation && el.operation === 'delete') { // delete member from group
removeValues.push({ value: el.value })
} else { // add member to group/
addValues.push({ value: el.value })
}
})
if (addValues.length < 1 && removeValues.length < 1) return null
body = { Operations: [] }
if (addValues.length > 0) {
body.Operations.push(
{
op: 'add',
path: 'members',
value: addValues,
},
)
}
if (removeValues.length > 0) {
body.Operations.push(
{
op: 'remove',
path: 'members',
value: removeValues,
},
)
}
}
const method = 'PATCH'
const path = `/Groups/${id}`
try {
const response = await helper.doRequest(baseEntity, method, path, body, ctx)
if (response.statusCode < 200 || response.statusCode > 299) {
throw new Error(`${response.statusMessage} - ${JSON.stringify(response.body)}`)
}
return null
} catch (err: any) {
throw new Error(`${action} error: ${err.message}`)
}
}
// =================================================
// helpers
// =================================================
//
// Cleanup on exit
//
process.on('SIGTERM', () => { // kill
})
process.on('SIGINT', () => { // Ctrl+C
})