@keepsolutions/scimgateway
Version:
Using SCIM protocol as a gateway for user provisioning to other endpoints
1,002 lines (890 loc) • 43.2 kB
JavaScript
// =================================================================================
// File: plugin-ldap.js
//
// Author: Jarle Elshaug
//
// Purpose: General ldap plugin having plugin-ldap.json configured for Active Directory.
// Using endpointMapper for attribute flexibility. Includes some special logic
// for Active Directory specific attributes like userAccountControl, unicodePW
// and objectGUID - objectGUID can also be used as id instead of Distinguished Name
//
// Attributes according to map definition in the configuration file plugin-ldap.json:
//
// GlobalUser Template Scim Endpoint
// -----------------------------------------------------------------------------------------------
// User name %AC% userName sAMAccountName
// id dn
// Suspended - active userAccountControl
// Password %P% password unicodePwd
// First Name %UF% name.givenName givenName
// Last Name %UL% name.familyName sn
// Full Name %UN% name.formatted name
// Job title %UT% title title
// groups.value memberOf
// Emails emails.work.value mail
// Phones phoneNumbers.home.value homePhone
// phoneNumbers.work.value mobile
// Addresses addresses.work.postalCode postalCode
// addresses.work.streetAddress streetAddress
// addresses.work.locality l
// addresses.work.region st
// addresses.work.country co
// Entitlements (for general purposes) entitlements.description.value description
// entitlements.lastLogonTimestamp.value lastLogonTimestamp
// entitlements.homeDirectory.value homeDirectory
// entitlements.homeDrive.value homeDrive
// entitlements.telephoneNumber.value telephoneNumber
// entitlements.physicalDeliveryOfficeName.value physicalDeliveryOfficeName
// createUser override userBase entitlements.userBase.value N/A
//
// Groups:
// id dn
// displayName cn
// members.value member
//
// =================================================================================
const ldap = require('ldapjs')
// mandatory plugin initialization - start
const path = require('path')
const { setUncaughtExceptionCaptureCallback } = require('process')
let ScimGateway = null
try {
ScimGateway = require('scimgateway')
} catch (err) {
ScimGateway = require('./scimgateway')
}
const scimgateway = new ScimGateway()
const pluginName = path.basename(__filename, '.js')
const configDir = path.join(__dirname, '..', 'config')
const configFile = path.join(`${configDir}`, `${pluginName}.json`)
let config = require(configFile).endpoint
config = scimgateway.processExtConfig(pluginName, config) // add any external config process.env and process.file
// mandatory plugin initialization - end
const _serviceClient = {}
// =================================================
// exploreUsers
// =================================================
scimgateway.exploreUsers = async (baseEntity, attributes, startIndex, count) => {
const action = 'exploreUsers'
scimgateway.logger.debug(`${pluginName}[${baseEntity}] handling "${action}" attributes=${attributes} startIndex=${startIndex} count=${count}`)
const result = { // itemsPerPage will be set by scimgateway
Resources: [],
totalResults: null
}
if (!attributes) attributes = 'id,userName,attributes=profileUrl,entitlements,x509Certificates.value,preferredLanguage,addresses,displayName,timezone,name.middleName,roles,locale,title,photos,meta.location,ims,phoneNumbers,emails,meta.version,name.givenName,name.honorificSuffix,name.honorificPrefix,name.formatted,nickName,meta.created,active,externalId,meta.lastModified,name.familyName,userType,groups.value'
const [parsedAttr] = scimgateway.endpointMapper('outbound', attributes, config.map.user) // SCIM/CustomSCIM => endpoint attribute naming
const method = 'search'
const scope = 'sub'
const base = config.entity[baseEntity].ldap.userBase
const attrs = parsedAttr.split(',')
const [userIdAttr, err] = scimgateway.endpointMapper('outbound', 'userName', config.map.user) // e.g. 'userName' => 'sAMAccountName'
if (err) throw err
const ldapOptions = {
filter: `&${getObjClassFilter(baseEntity, 'user')}(${userIdAttr}=*)`,
scope: scope,
attributes: attrs
}
try {
const users = await doRequest(baseEntity, method, base, ldapOptions) // ignoring SCIM paging startIndex/count - get all
result.totalResults = users.length
result.Resources = users.map((user) => {
return scimgateway.endpointMapper('inbound', user, config.map.user)[0] // endpoint attribute naming => SCIM
})
result.Resources = await Promise.all(users.map(async (user) => { // Promise.all because of async map
if (user.memberOf) {
if (!config.map.group) user.memberOf = [] // empty any values
else if (config.map.group.objectGUID && config.map.group.objectGUID.mapTo === 'id') {
const arr = []
if (Array.isArray(user.memberOf)) {
for (let i = 0; i < user.memberOf.length; i++) {
const guid = await dnToGuid(baseEntity, user.memberOf[i])
if (!guid) throw new Error(`dnToGuid did not return any objectGUID value for dn=${user.memberOf[i]}`)
arr.push(guid)
}
user.memberOf = arr
} else {
const guid = await dnToGuid(baseEntity, user.memberOf)
if (!guid) throw new Error(`dnToGuid did not return any objectGUID value for dn=${user.memberOf}`)
user.memberOf = [guid]
}
}
}
return scimgateway.endpointMapper('inbound', user, config.map.user)[0] // endpoint attribute naming => SCIM
}))
} catch (err) {
const newErr = err
throw newErr
}
return result
}
// =================================================
// exploreGroups
// =================================================
scimgateway.exploreGroups = async (baseEntity, attributes, startIndex, count) => {
const action = 'exploreGroups'
scimgateway.logger.debug(`${pluginName}[${baseEntity}] handling "${action}" attributes=${attributes} startIndex=${startIndex} count=${count}`)
const result = { // itemsPerPage will be set by scimgateway
Resources: [],
totalResults: null
}
if (!config.map.group) { // not using groups
scimgateway.logger.debug(`${pluginName}[${baseEntity}] "${action}" stopped - missing configuration endpoint.map.group`)
return result
}
if (!attributes) attributes = 'id,displayName,members.value'
const [parsedAttr, err] = scimgateway.endpointMapper('outbound', attributes, config.map.group) // SCIM/CustomSCIM => endpoint attribute naming
if (err) throw err
const method = 'search'
const scope = 'sub'
const base = config.entity[baseEntity].ldap.groupBase
const attrs = parsedAttr.split(',')
const [groupDisplayNameAttr, err1] = scimgateway.endpointMapper('outbound', 'displayName', config.map.group) // e.g. 'displayName' => 'cn'
if (err1) throw err1
const ldapOptions = {
filter: `&${getObjClassFilter(baseEntity, 'group')}(${groupDisplayNameAttr}=*)`,
scope: scope,
attributes: attrs
}
try {
const groups = await doRequest(baseEntity, method, base, ldapOptions) // ignoring SCIM paging startIndex/count - get all
result.totalResults = groups.length
result.Resources = await Promise.all(groups.map(async (group) => { // Promise.all because of async map
if (config.map.user.objectGUID && config.map.user.objectGUID.mapTo === 'id') {
if (group.member) {
const arr = []
if (Array.isArray(group.member)) {
for (let i = 0; i < group.member.length; i++) {
const guid = await dnToGuid(baseEntity, group.member[i])
if (!guid) throw new Error(`dnToGuid did not return any objectGUID value for dn=${group.member[i]}`)
arr.push(guid)
}
group.member = arr
} else {
const guid = await dnToGuid(baseEntity, group.member)
if (!guid) throw new Error(`dnToGuid did not return any objectGUID value for dn=${group.member}`)
group.member = [guid]
}
}
}
return scimgateway.endpointMapper('inbound', group, config.map.group)[0] // endpoint attribute naming => SCIM
}))
} catch (err) {
const newErr = err
throw newErr
}
return result
}
// =================================================
// getUser
// =================================================
scimgateway.getUser = async (baseEntity, getObj, attributes) => {
// getObj = { filter: <filterAttribute>, identifier: <identifier> }
// e.g: getObj = { filter: 'userName', identifier: 'bjensen'}
// filter: userName and id must be supported
// (they are most often considered as "the same" where identifier = UserID )
// Note, the value of id attribute returned will be used by modifyUser and deleteUser
// attributes: if not blank, attributes listed should be returned
// Should normally return all supported user attributes having id and userName as mandatory
// SCIM Gateway will automatically filter response according to the attributes list
const action = 'getUser'
scimgateway.logger.debug(`${pluginName}[${baseEntity}] handling "${action}" ${getObj.filter}=${getObj.identifier} attributes=${attributes}`)
if (!config.map || !config.map.user) throw new Error(`${action} error: missing configuration endpoint.map.user`)
if (!config.entity[baseEntity] || !config.entity[baseEntity].ldap) throw new Error(`${action} error: missing configuration for endpoint.${baseEntity}.ldap`)
if (!attributes) attributes = 'id,userName,attributes=profileUrl,entitlements,x509Certificates.value,preferredLanguage,addresses,displayName,timezone,name.middleName,roles,locale,title,photos,meta.location,ims,phoneNumbers,emails,meta.version,name.givenName,name.honorificSuffix,name.honorificPrefix,name.formatted,nickName,meta.created,active,externalId,meta.lastModified,name.familyName,userType,groups.value'
const [parsedAttr] = scimgateway.endpointMapper('outbound', attributes, config.map.user) // SCIM/CustomSCIM => endpoint attribute naming
const method = 'search'
const scope = 'sub'
let base = config.entity[baseEntity].ldap.userBase
const attrs = parsedAttr.split(',')
let ldapOptions = {}
if (getObj.filter === 'id') { // using dn or objectGUID (Active Directory) lookup instead of search
if (config.map.user.objectGUID && config.map.user.objectGUID.mapTo === 'id') base = `<GUID=${getObj.identifier}>` // '<GUID=b3975b675d3a21498b4e511e1a8ccb9e>'
else base = getObj.identifier
ldapOptions = {
attributes: attrs
}
} else {
const [userIdAttr, err] = scimgateway.endpointMapper('outbound', getObj.filter, config.map.user) // e.g. 'userName' => 'sAMAccountName'
if (err) throw err
ldapOptions = {
filter: `&${getObjClassFilter(baseEntity, 'user')}(${userIdAttr}=${getObj.identifier})`, // &(objectClass=user)(objectClass=person)(objectClass=organizationalPerson)(objectClass=top)(sAMAccountName=bjensen)
scope: scope,
attributes: attrs
}
}
try {
const users = await doRequest(baseEntity, method, base, ldapOptions)
if (users.length === 0) throw new Error(`${action} error: ${getObj.identifier} not found`)
else if (users.length > 1) throw new Error(`${action} error: ${ldapOptions.filter} returned more than one user`)
const userObj = users[0]
if (userObj.name) delete userObj.name // because map converts to SCIM name.xxx
// endpoint spesific attribute handling
// "active" must be handled separate
if (userObj.userAccountControl !== undefined) { // SCIM "active" - Active Directory
const userAccountControl = userObj.userAccountControl// ACCOUNTDISABLE 0x0002
if ((userAccountControl & 0x0002) === 0x0002) userObj.userAccountControl = false
else userObj.userAccountControl = true
}
if (userObj.memberOf) {
if (!config.map.group) userObj.memberOf = [] // empty any values
else if (config.map.group.objectGUID && config.map.group.objectGUID.mapTo === 'id') { // Active Directory using objectGUID - convert memberOf having dn values to objectGUID
const arr = []
try {
if (Array.isArray(userObj.memberOf)) {
for (let i = 0; i < userObj.memberOf.length; i++) {
const guid = await dnToGuid(baseEntity, userObj.memberOf[i])
if (!guid) throw new Error(`${action} dnToGuid did not return any objectGUID value for dn=${userObj.memberOf[i]}`)
arr.push(guid)
}
userObj.memberOf = arr
} else {
const guid = await dnToGuid(baseEntity, userObj.memberOf)
if (!guid) throw new Error(`dnToGuid did not return any objectGUID value for dn=${userObj.memberOf}`)
userObj.memberOf = [guid]
}
} catch (err) {
const newErr = err
throw newErr
}
}
}
// convert endpoint attributes to SCIM attributes according to config.map
const [obj, err] = scimgateway.endpointMapper('inbound', userObj, config.map.user) // endpoint attribute naming => SCIM
if (err) throw err
if (obj.active !== undefined) {
if (obj.active !== true && obj.active !== false) {
throw new Error(`${action} error: missing plugin logic for handling attribute "active"`)
}
}
return obj
} catch (err) {
const newErr = err
throw newErr
}
}
// =================================================
// createUser
// =================================================
scimgateway.createUser = async (baseEntity, userObj) => {
const action = 'createUser'
scimgateway.logger.debug(`${pluginName}[${baseEntity}] handling "${action}" userObj=${JSON.stringify(userObj)}`)
let userBase = null
if (userObj.entitlements && userObj.entitlements.userbase) { // override default userBase (type userbase will be lowercase)
if (userObj.entitlements.userbase.value) {
userBase = userObj.entitlements.userbase.value // temporary and not in config.map, will not be sent to endpoint
}
}
if (!userBase) userBase = config.entity[baseEntity].ldap.userBase
// convert SCIM attributes to endpoint attributes according to config.map
const [endpointObj, err] = scimgateway.endpointMapper('outbound', userObj, config.map.user)
if (err) throw err
// endpoint spesific attribute handling
if (endpointObj.sAMAccountName !== undefined) { // Active Directory
const userAccountControl = 512 // NORMAL_ACCOUNT
endpointObj.userAccountControl = userAccountControl ^ 0x0002 // disable user (will be enabled if password provided)
if (endpointObj.mail) endpointObj.userPrincipalName = endpointObj.mail
else endpointObj.userPrincipalName = endpointObj.sAMAccountName
}
if (endpointObj.unicodePwd) { // Active Directory - SCIM "password" - UTF16LE encoded password in quotes
let pwd = ''
const str = `"${endpointObj.unicodePwd}"`
for (let i = 0; i < str.length; i++) {
pwd += String.fromCharCode(str.charCodeAt(i) & 0xFF, (str.charCodeAt(i) >>> 8) & 0xFF)
}
endpointObj.unicodePwd = pwd
const userAccountControl = 512 // NORMAL_ACCOUNT
endpointObj.userAccountControl = userAccountControl & (~0x0002) // enable user
endpointObj.pwdLastSet = 0 // user must change password on next logon
}
// endpointObj.objectClass is mandatory and must must match your ldap schema
endpointObj.objectClass = config.entity[baseEntity].ldap.userObjectClasses // Active Directory: ["user", "person", "organizationalPerson", "top"]
const method = 'add'
const base = `${config.entity[baseEntity].ldap.userNamingAttr}=${userObj.userName},${userBase}`
const ldapOptions = endpointObj
try {
await doRequest(baseEntity, method, base, ldapOptions)
return null
} catch (err) {
const newErr = new Error(err.message)
if (newErr.message.includes('ENTRY_EXISTS')) newErr.name = 'DuplicateKeyError' // gives scimgateway statuscode 409 instead of default 500
throw newErr
}
}
// =================================================
// deleteUser
// =================================================
scimgateway.deleteUser = async (baseEntity, id) => {
const action = 'deleteUser'
scimgateway.logger.debug(`${pluginName}[${baseEntity}] handling "${action}" id=${id}`)
const method = 'del'
let base
if (config.map.user.objectGUID && config.map.user.objectGUID.mapTo === 'id') base = `<GUID=${id}>` // AD objectGUID
else base = id // dn
const ldapOptions = {}
try {
await doRequest(baseEntity, method, base, ldapOptions)
return null
} catch (err) {
const newErr = err
throw newErr
}
}
// =================================================
// modifyUser
// =================================================
scimgateway.modifyUser = async (baseEntity, id, attrObj) => {
const action = 'modifyUser'
scimgateway.logger.debug(`${pluginName}[${baseEntity}] handling "${action}" id=${id} attrObj=${JSON.stringify(attrObj)}`)
// groups must be handled separate - using group member of user and not user member of group
if (attrObj.groups) { // not supported by AD - will fail (not allowing updating users memberOf attribute, must update group instead of user)
const groups = attrObj.groups
delete attrObj.groups // make sure to be removed from attrObj
const [groupsAttr, err] = scimgateway.endpointMapper('outbound', 'groups.value', config.map.user)
if (err) throw err
const body = { add: { }, remove: { } }
body.add[groupsAttr] = []
body.remove[groupsAttr] = []
for (let i = 0; i < groups.length; i++) {
const el = groups[i]
if (el.operation && el.operation === 'delete') { // delete from users group attribute
body.remove[groupsAttr].push(el.value)
} else { // add to users group attribute
body.add[groupsAttr].push(el.value)
}
}
const method = 'modify'
let base
if (config.map.user.objectGUID && config.map.user.objectGUID.mapTo === 'id') base = `<GUID=${id}>` // AD objectGUID
else base = id // dn
try {
if (body.add[groupsAttr].length > 0) {
const ldapOptions = {
operation: 'add',
modification: body.add
}
await doRequest(baseEntity, method, base, ldapOptions)
}
if (body.remove[groupsAttr].length > 0) {
const ldapOptions = {
operation: 'delete',
modification: body.remove
}
await doRequest(baseEntity, method, base, ldapOptions)
}
} catch (err) {
const newErr = err
throw newErr
}
}
if (JSON.stringify(attrObj) === '{}') return null // only groups included
// convert SCIM attributes to endpoint attributes according to config.map
const [endpointObj, err] = scimgateway.endpointMapper('outbound', attrObj, config.map.user)
if (err) throw err
// endpoint spesific attribute handling
if (endpointObj.userAccountControl !== undefined) { // SCIM "active" - Active Directory
// can't use getUser because there is "active" logic overriding original userAccountControl that we want
// const usr = await scimgateway.getUser(baseEntity, { filter: 'id', identifier: id }, 'active')
const activeAttr = 'userAccountControl'
const method = 'search'
let base
if (config.map.user.objectGUID && config.map.user.objectGUID.mapTo === 'id') base = `<GUID=${id}>` // AD objectGUID
else base = id // dn
const ldapOptions = {
attributes: activeAttr
}
const users = await doRequest(baseEntity, method, base, ldapOptions)
if (users.length === 0) throw new Error(`${action} error: ${id} not found`)
else if (users.length > 1) throw new Error(`${action} error: ${ldapOptions.filter} returned more than one user for ${id}`)
const usr = users[0]
let userAccountControl
if (usr.userAccountControl) userAccountControl = usr.userAccountControl // ACCOUNTDISABLE 0x0002
else throw new Error(`${action} error: did not retrieve any value for attribute "${activeAttr}/active"`)
if (attrObj.active === false) userAccountControl = userAccountControl ^ 0x0002 // disable user
else userAccountControl = userAccountControl = userAccountControl & (~(0x0002 + 0x0010)) // enable user, also turn off any LOCKOUT 0x0010
endpointObj.userAccountControl = userAccountControl // now converted from active (true/false) to userAccountControl
}
if (endpointObj.unicodePwd) { // SCIM "password" - Active Directory - UTF16LE encoded password in quotes
let pwd = ''
const str = `"${endpointObj.unicodePwd}"`
for (let i = 0; i < str.length; i++) {
pwd += String.fromCharCode(str.charCodeAt(i) & 0xFF, (str.charCodeAt(i) >>> 8) & 0xFF)
}
endpointObj.unicodePwd = pwd
}
const method = 'modify'
let base
if (config.map.user.objectGUID && config.map.user.objectGUID.mapTo === 'id') base = `<GUID=${id}>` // AD objectGUID
else base = id // dn
const ldapOptions = {
operation: 'replace',
modification: endpointObj
}
try {
await doRequest(baseEntity, method, base, ldapOptions)
return null
} catch (err) {
const newErr = err
throw newErr
}
}
// =================================================
// getGroup
// =================================================
scimgateway.getGroup = async (baseEntity, getObj, attributes) => {
// getObj = { filter: <filterAttribute>, identifier: <identifier> }
// e.g: getObj = { filter: 'displayName', identifier: 'GroupA' }
// filter: displayName and id must be supported
// (they are most often considered as "the same" where identifier = GroupName)
// Note, the value of id attribute returned will be used by deleteGroup, getGroupMembers and modifyGroup
// attributes: if not blank, attributes listed should be returned
// Should normally return all supported group attributes having id, displayName and members as mandatory
// members may be skipped if attributes is not blank and do not contain members or members.value
const action = 'getGroup'
scimgateway.logger.debug(`${pluginName}[${baseEntity}] handling "${action}" ${getObj.filter}=${getObj.identifier} attributes=${attributes}`)
if (!config.map.group) throw new Error(`${action} error: missing configuration endpoint.map.group`)
if (!attributes) attributes = 'id,displayName,members.value'
const [parsedAttr] = scimgateway.endpointMapper('outbound', attributes, config.map.group) // SCIM/CustomSCIM => endpoint attribute naming
const method = 'search'
const scope = 'sub'
let base = config.entity[baseEntity].ldap.groupBase
const attrs = parsedAttr.split(',')
let ldapOptions = {}
if (getObj.filter === 'id') { // using dn or objectGUID (Active Directory) lookup instead of search
if (config.map.group.objectGUID && config.map.group.objectGUID.mapTo === 'id') base = `<GUID=${getObj.identifier}>` // '<GUID=b3975b675d3a21498b4e511e1a8ccb9e>'
else base = getObj.identifier
ldapOptions = {
attributes: attrs
}
} else {
const [groupIdAttr, err] = scimgateway.endpointMapper('outbound', getObj.filter, config.map.group)
if (err) throw err
ldapOptions = {
filter: `&${getObjClassFilter(baseEntity, 'group')}(${groupIdAttr}=${getObj.identifier})`, // &(objectClass=group)(cn=Group1)
scope: scope,
attributes: attrs
}
}
try {
const groups = await doRequest(baseEntity, method, base, ldapOptions)
if (groups.length === 0) throw new Error(`${action} error: ${getObj.identifier} not found`)
else if (groups.length > 1) throw new Error(`${action} error: ${ldapOptions.filter} returned more than one group`)
const groupObj = groups[0]
if (config.map.user.objectGUID && config.map.user.objectGUID.mapTo === 'id') { // Active Directory using objectGUID - convert member having dn values to objectGUID
if (groupObj.member) {
const arr = []
try {
if (Array.isArray(groupObj.member)) {
for (let i = 0; i < groupObj.member.length; i++) {
const guid = await dnToGuid(baseEntity, groupObj.member[i])
if (!guid) throw new Error(`${action} dnToGuid did not return any objectGUID value for dn=${groupObj.member[i]}`)
arr.push(guid)
}
groupObj.member = arr
} else {
const guid = await dnToGuid(baseEntity, groupObj.member)
if (!guid) throw new Error(`${action} dnToGuid did not return any objectGUID value for dn=${groupObj.member}`)
groupObj.member = [guid]
}
} catch (err) {
const newErr = err
throw newErr
}
}
}
const [obj, err] = scimgateway.endpointMapper('inbound', groupObj, config.map.group) // endpoint attribute naming => SCIM
if (err) throw err
return obj
} catch (err) {
const newErr = err
throw newErr
}
}
// =================================================
// getGroupMembers
// =================================================
scimgateway.getGroupMembers = async (baseEntity, id, attributes) => {
// return all groups the user is member of having attributes included e.g: members.value,id,displayName
// method used when "users member of group", if used - getUser must treat user attribute groups as virtual readOnly attribute
// "users member of group" is SCIM default and this method should normally have some logic
const action = 'getGroupMembers'
scimgateway.logger.debug(`${pluginName}[${baseEntity}] handling "${action}" user id=${id} attributes=${attributes}`)
if (!config.map.group) { // not using groups
scimgateway.logger.debug(`${pluginName}[${baseEntity}] "${action}" stopped - missing configuration endpoint.map.group`)
return
}
let idDn = id
if (config.map.user.objectGUID && config.map.user.objectGUID.mapTo === 'id') { // need dn
const method = 'search'
const base = `<GUID=${id}>`
const ldapOptions = {
attributes: ['dn']
}
try {
const users = await doRequest(baseEntity, method, base, ldapOptions)
if (users.length !== 1) throw new Error(`${action} error: did not find unique user having objectGUID=${id}`)
idDn = users[0].dn
} catch (err) {
const newErr = err
throw newErr
}
}
attributes = 'id,displayName' // using hardcoded attributes - probably requested: 'members.value,displayName'
const [parsedAttr, err] = scimgateway.endpointMapper('outbound', attributes, config.map.group) // SCIM/CustomSCIM => endpoint attribute naming
if (err) throw err
const [memberAttr, err1] = scimgateway.endpointMapper('outbound', 'members.value', config.map.group)
if (err1) throw err1
const method = 'search'
const scope = 'sub'
const base = config.entity[baseEntity].ldap.groupBase
const attrs = parsedAttr.split(',')
const ldapOptions = {
filter: `&${getObjClassFilter(baseEntity, 'group')}(${memberAttr}=${idDn})`,
scope: scope,
attributes: attrs
}
try {
const groups = await doRequest(baseEntity, method, base, ldapOptions)
return groups.map((grp) => {
return { // { id: <id-group>> , displayName: <displayName-group>, members [{value: <id-user>}] }
id: encodeURIComponent(grp[attrs[0]]), // not mandatory, but included anyhow
displayName: grp[attrs[1]], // displayName is mandatory
members: [{ value: encodeURIComponent(id) }] // only includes current user
}
})
} catch (err) {
const newErr = err
throw newErr
}
}
// =================================================
// getGroupUsers
// =================================================
scimgateway.getGroupUsers = async (baseEntity, id, attributes) => {
// return array of all users that is member of this group id having attributes included e.g: groups.value,userName
// method used when "group member of users", if used - getGroup must treat group attribute members as virtual readOnly attribute
const action = 'getGroupUsers'
scimgateway.logger.debug(`${pluginName}[${baseEntity}] handling "${action}" id=${id} attributes=${attributes}`)
const arrRet = []
if (!attributes) attributes = 'groups.value,userName'
const [parsedAttr, err] = scimgateway.endpointMapper('outbound', attributes, config.map.user) // SCIM/CustomSCIM => endpoint attribute naming
if (err) throw err
const method = 'search'
const scope = 'sub'
const base = config.entity[baseEntity].ldap.userBase
const attrs = parsedAttr.split(',')
const [groupIdAttr, err1] = scimgateway.endpointMapper('outbound', 'groups.value', config.map.user)
if (err1) throw err
const ldapOptions = {
filter: `&${getObjClassFilter(baseEntity, 'user')}(${groupIdAttr}=${id})`,
scope: scope,
attributes: attrs
}
try {
const users = await doRequest(baseEntity, method, base, ldapOptions)
users.forEach(el => {
const [usr, err1] = scimgateway.endpointMapper('inbound', el, config.map.user)
if (err1) throw err
usr.groups = [{ value: id }] // override retrieved groups including all, instead include only the group asked for
arrRet.push(usr) // {userName: "bjensen", groups[{value: <encodedURI dn>}]} - value only includes current id
})
} catch (err) {
const newErr = err
throw newErr
}
return arrRet
}
// =================================================
// createGroup
// =================================================
scimgateway.createGroup = async (baseEntity, groupObj) => {
const action = 'createGroup'
scimgateway.logger.debug(`${pluginName}[${baseEntity}] handling "${action}" groupObj=${JSON.stringify(groupObj)}`)
if (!config.map.group) throw new Error(`${action} error: missing configuration endpoint.map.group`)
// convert SCIM attributes to endpoint attributes according to config.map
const [endpointObj, err] = scimgateway.endpointMapper('outbound', groupObj, config.map.group)
if (err) throw err
// endpointObj.objectClass is mandatory and must must match your ldap schema
endpointObj.objectClass = config.entity[baseEntity].ldap.groupObjectClasses // Active Directory: ["group"]
const method = 'add'
const base = `${config.entity[baseEntity].ldap.groupNamingAttr}=${groupObj.displayName},${config.entity[baseEntity].ldap.groupBase}`
const ldapOptions = endpointObj
try {
await doRequest(baseEntity, method, base, ldapOptions)
return null
} catch (err) {
const newErr = new Error(err.message)
if (newErr.message.includes('ENTRY_EXISTS')) newErr.name = 'DuplicateKeyError' // gives scimgateway statuscode 409 instead of default 500
throw newErr
}
}
// =================================================
// deleteGroup
// =================================================
scimgateway.deleteGroup = async (baseEntity, id) => {
const action = 'deleteGroup'
scimgateway.logger.debug(`${pluginName}[${baseEntity}] handling "${action}" id=${id}`)
if (!config.map.group) throw new Error(`${action} error: missing configuration endpoint.map.group`)
const method = 'del'
let base
if (config.map.group.objectGUID && config.map.group.objectGUID.mapTo === 'id') base = `<GUID=${id}>` // AD objectGUID
else base = id // dn
const ldapOptions = {}
try {
await doRequest(baseEntity, method, base, ldapOptions)
return null
} catch (err) {
const newErr = err
throw newErr
}
}
// =================================================
// modifyGroup
// =================================================
scimgateway.modifyGroup = async (baseEntity, id, attrObj) => {
const action = 'modifyGroup'
scimgateway.logger.debug(`${pluginName}[${baseEntity}] handling "${action}" id=${id} attrObj=${JSON.stringify(attrObj)}`)
if (!config.map.group) throw new Error(`${action} error: missing configuration endpoint.map.group`)
if (!attrObj.members) {
throw new Error(`plugin handling "${action}" only supports modification of members`)
}
if (!Array.isArray(attrObj.members)) {
throw new Error(`plugin handling "${action}" error: ${JSON.stringify(attrObj)} - correct syntax is { "members": [...] }`)
}
const [memberAttr, err1] = scimgateway.endpointMapper('outbound', 'members.value', config.map.group)
if (err1) throw err1
const body = { add: { }, remove: { } }
body.add[memberAttr] = []
body.remove[memberAttr] = []
for (let i = 0; i < attrObj.members.length; i++) {
const el = attrObj.members[i]
if (config.map.user.objectGUID && config.map.user.objectGUID.mapTo === 'id') {
const dn = await guidToDn(baseEntity, el.value)
if (!dn) throw new Error(`dnToGuid did not return any objectGUID value for dn=${el.value}`)
el.value = dn
}
if (el.operation && el.operation === 'delete') { // delete member from group
body.remove[memberAttr].push(el.value) // endpointMapper returns URI encoded id because some IdP's don't encode id used in GET url e.g. Symantec/Broadcom/CA
} else { // add member to group
body.add[memberAttr].push(el.value)
}
}
const method = 'modify'
let base
if (config.map.group.objectGUID && config.map.group.objectGUID.mapTo === 'id') base = `<GUID=${id}>` // AD objectGUID
else base = id // dn
try {
if (body.add[memberAttr].length > 0) {
const ldapOptions = { // using ldap lookup (dn) instead of search
operation: 'add',
modification: body.add
}
await doRequest(baseEntity, method, base, ldapOptions)
}
if (body.remove[memberAttr].length > 0) {
const ldapOptions = { // using ldap lookup (dn) instead of search
operation: 'delete',
modification: body.remove
}
await doRequest(baseEntity, method, base, ldapOptions)
}
return null
} catch (err) {
const newErr = err
throw newErr
}
}
// =================================================
// helpers
// =================================================
//
// getObjClassFilter returns object classes to be included in search
//
const getObjClassFilter = (baseEntity, type) => {
let filter = ''
switch (type) {
case 'user':
for (let i = 0; i < config.entity[baseEntity].ldap.userObjectClasses.length; i++) {
filter += `(objectClass=${config.entity[baseEntity].ldap.userObjectClasses[i]})`
}
break
case 'group':
for (let i = 0; i < config.entity[baseEntity].ldap.groupObjectClasses.length; i++) {
filter += `(objectClass=${config.entity[baseEntity].ldap.groupObjectClasses[i]})`
}
break
}
return filter
}
//
// dnToGuid is used for Active Directory to return objectGUID based on dn
//
const dnToGuid = async (baseEntity, dn) => {
const method = 'search'
const ldapOptions = {
attributes: ['objectGUID']
}
try {
const base = dn
const objects = await doRequest(baseEntity, method, base, ldapOptions)
if (objects.length !== 1) throw new Error(`did not find unique object having dn=${base}`)
return objects[0].objectGUID
} catch (err) {
const newErr = err
throw newErr
}
}
//
// guidToDn is used for Active Directory to return dn based on objectGUID
//
const guidToDn = async (baseEntity, guid) => {
const method = 'search'
const ldapOptions = {
attributes: ['dn']
}
try {
const base = `<GUID=${guid}>`
const objects = await doRequest(baseEntity, method, base, ldapOptions)
if (objects.length !== 1) throw new Error(`did not find unique object having objectGUID=${base}`)
return objects[0].dn
} catch (err) {
const newErr = err
throw newErr
}
}
//
// getServiceClient - returns LDAP client used by doRequest
//
const getServiceClient = async (baseEntity) => {
const action = 'getServiceClient'
if (!config.entity[baseEntity].passwordDecrypted) config.entity[baseEntity].passwordDecrypted = scimgateway.getPassword(`endpoint.entity.${baseEntity}.password`, configFile)
if (!config.entity[baseEntity].baseUrl) config.entity[baseEntity].baseUrl = config.entity[baseEntity].baseUrls[0] // failover logic also updates baseUrl
if (!_serviceClient[baseEntity]) _serviceClient[baseEntity] = {}
for (let i = -1; i < config.entity[baseEntity].baseUrls.length; i++) {
let useStrictDN = true
if (config.map.user.objectGUID && config.map.user.objectGUID.mapTo === 'id') useStrictDN = false
try {
const cli = await ldap.createClient({
url: config.entity[baseEntity].baseUrl,
connectTimeout: 5000,
tlsOptions: {
rejectUnauthorized: false
},
strictDN: useStrictDN // false => supports objectGUID as dn
})
await new Promise((resolve, reject) => {
cli.bind(config.entity[baseEntity].username, config.entity[baseEntity].passwordDecrypted, (err) => err ? reject(err) : resolve())
})
return cli // client OK
} catch (err) {
if (i + 1 < config.entity[baseEntity].baseUrls.length) { // failover logic
scimgateway.logger.debug(`${pluginName}[${baseEntity}] baseUrl=${config.entity[baseEntity].baseUrl} connection error but will retry`)
config.entity[baseEntity].baseUrl = config.entity[baseEntity].baseUrls[i + 1]
} else {
const newErr = new Error(`${action} error: ${err.message}`)
throw newErr
}
}
}
throw new Error(`${action} error: program logic failed for some odd reasons - should not happend...`)
}
//
// doRequest - execute LDAP request
//
// method: "search" or "modify"
// base: <baseDN>
// ldapOptions: according to ldapjs module
// e.g.: {
// "filter": "&(objectClass=user)(sAMAccountName=*)",
// "scope": "sub",
// "attributes": ["sAMAccountName","displayName","mail"]
// }
//
const doRequest = async (baseEntity, method, base, ldapOptions) => {
let result = null
let client = null
const options = scimgateway.copyObj(ldapOptions)
try {
client = await getServiceClient(baseEntity)
switch (method) {
case 'search':
options.paged = { pageSize: 200, pagePause: false } // parse entire directory calling 'page' method for each page
result = await new Promise((resolve, reject) => {
const results = []
client.search(base, options, (err, search) => {
if (err) {
return reject(err)
}
search.on('searchEntry', (entry) => {
if (entry.attributes) {
entry.attributes.find((el, i) => {
if (el.type === 'objectGUID') { // assume Active Directory - can't use default utf-8 when attribute value is hex
const hexStr = Buffer.from(el.buffers[0], 'binary').toString('hex')
entry.attributes[i]._vals = [hexStr]
}
return undefined
})
}
results.push(entry.object)
})
search.on('page', (entry, cb) => {
// if (cb) cb() // pagePause = true gives callback
})
search.on('error', (err) => reject(err))
search.on('end', (_) => resolve(results))
})
})
break
case 'modify':
result = await new Promise((resolve, reject) => {
const dn = base
client.modify(dn, options, (err) => {
if (err) {
return reject(err)
}
resolve()
})
})
break
case 'add':
result = await new Promise((resolve, reject) => {
client.add(base, options, (err) => {
if (err) {
return reject(err)
}
resolve()
})
})
break
case 'del':
result = await new Promise((resolve, reject) => {
client.del(base, (err) => {
if (err) {
return reject(err)
}
resolve()
})
})
break
default:
throw new Error('unsupported method')
}
client.unbind()
} catch (err) {
scimgateway.logger.error(`${pluginName}[${baseEntity}] doRequest method=${method} base=${base} ldapOptions=${JSON.stringify(ldapOptions)} Error Response = ${err.message}`)
const newErr = err
if (client) {
try { client.unbind() } catch (err) {}
}
throw newErr
}
scimgateway.logger.debug(`${pluginName}[${baseEntity}] doRequest method=${method} base=${base} ldapOptions=${JSON.stringify(ldapOptions)} Response=${JSON.stringify(result)}`)
return result
} // doRequest
//
// Cleanup on exit
//
process.on('SIGTERM', () => { // kill
})
process.on('SIGINT', () => { // Ctrl+C
})